Compare commits

..
Author SHA1 Message Date
Mohamed Boudra c708c263c8 fix(app): retain checkout selection after workspace failure
Return the submitted checkout through the existing draft failure handoff. Keep client-owned receipt ordering and retries out of app presentation, and retain direct coverage of newer draft cleanup while removing the unused background-agent wrapper.
2026-10-26 12:02:52 +01:00
Mohamed Boudra 0b55d63ab1 fix(app): preserve workspace creation state through adoption
Publish the last draft edit before capturing cleanup ownership and keep published workspaces working while their initial agent creation is pending. Update browser journeys for the normal editable agent panel and make the placement assertion portable across platforms.
2026-10-26 10:29:24 +01:00
Mohamed Boudra 3a3ccd5385 feat(app): open workspace chats immediately
Reserve the final workspace, agent, and initial message identities before preparation so the normal agent panel can own the conversation throughout creation. Keep follow-ups queued until the first prompt starts and hand failures back to the existing workspace form or failed agent draft.
2026-10-26 09:50:00 +01:00
354 changed files with 11016 additions and 18723 deletions
-28
View File
@@ -169,34 +169,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
-145
View File
@@ -85,151 +85,6 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
{
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
"rules": {
"paseo/no-sync-io": [
"error",
{
"allow": {
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
"executable-resolution/executable-resolution.ts": ["existsSync"],
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
"executable-resolution/windows.ts": ["readdirSync"],
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
"server/agent/providers/claude/agent.ts": ["existsSync"],
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
"server/agent/providers/claude/subagents/workflow-output.ts": [
"closeSync",
"openSync",
"readSync"
],
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
"server/agent/providers/omp/agent.ts": ["existsSync"],
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
"server/agent/providers/pi/agent.ts": [
"existsSync",
"mkdtempSync",
"readFileSync",
"rmSync",
"writeFileSync"
],
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
"server/config.ts": ["existsSync"],
// Startup identity: load or create the daemon keypair before accepting connections.
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
"server/host-name.ts": ["execFileSync"],
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
"server/hub/relationship-controller.ts": [
"existsSync",
"readFileSync",
"renameSync",
"rmSync"
],
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
"server/local-credential.ts": ["readFileSync"],
// Startup logger construction: create the log directory before opening the logging destination.
"server/logger.ts": ["mkdirSync"],
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
// Module initialization: the package version is resolved once and cached at import.
"server/package-version.ts": ["existsSync", "readFileSync"],
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
"server/persisted-config.ts": ["existsSync", "readFileSync"],
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
"server/pid-lock.ts": ["readFileSync"],
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
"server/plugins/builtin/index.ts": ["existsSync"],
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
// Build child process: select the esbuild binary for the isolated plugin compiler.
"server/plugins/compiler-esbuild.ts": ["existsSync"],
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
"server/private-files.ts": [
"chmodSync",
"mkdirSync",
"realpathSync",
"renameSync",
"rmSync",
"writeFileSync"
],
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
"server/push/token-store.ts": ["existsSync", "readFileSync"],
// Startup identity: load or create the server id before accepting connections.
"server/server-id.ts": ["existsSync", "readFileSync"],
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
// Speech child process: native addon discovery and patchelf run outside the daemon.
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
"existsSync",
"spawnSync"
],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
"server/web-ui.ts": ["readFileSync", "statSync"],
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
"server/workspace-registry-bootstrap.ts": ["statSync"],
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
"terminal/agent-hooks/agent-hook-installer.ts": [
"existsSync",
"readFileSync",
"rmSync"
],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
"terminal/terminal.ts": [
"accessSync",
"chmodSync",
"existsSync",
"mkdirSync",
"readFileSync",
"statSync"
],
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
"utils/path.ts": ["realpathSync"]
}
}
]
}
},
{
"files": [
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"packages/server/src/**/test-utils/**",
"packages/server/src/server/daemon-e2e/**",
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
],
"rules": { "paseo/no-sync-io": "off" }
},
{
"files": [
"plugins/*-provider/server/**/*.ts",
-9
View File
@@ -1,14 +1,5 @@
# 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
-9
View File
@@ -1,14 +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
### 修复
+44 -5
View File
@@ -340,11 +340,50 @@ Example: adding a new enum value
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
Creation execution is owned by the daemon across socket lifetimes. A workspace request
can include its initial agent and prompt. The app's `workspace-creation/` module owns
chat submission presentation outside React: it reserves final IDs and a message identity,
then opens the agent target before asynchronous preparation or a daemon response.
The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal
launches, and existing-workspace agent drafts retain their own entry points.
Pending presentation never enters the directory replica as a fabricated resource.
Routes, tabs, and the sidebar retain it until the corresponding authoritative resource
has been observed. Files, terminals, scripts, and agent timeline initialization wait for
real resources. The creating agent opens in the normal agent panel and composer, showing
its submitted configuration. Until the host publishes the agent, its destination cwd is
unknown and its controls are read-only. The host publishes the agent before it starts the
initial prompt, so prompts wait in the agent's queue until creation completes
(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send
and review feedback queue behind earlier prompts, and send now and queue drain leave the
queue untouched. The queue drains on adoption or when the initial turn ends. The first
message uses composer submission reconciliation; creation never sends it again when the
panel mounts. Completion does not navigate, and draft cleanup checks the captured version
so it cannot erase newer input.
A failed creation goes to the existing owner of whatever the host did not create, using
the receipt's resources and `failedStage`. Creation has no failure UI of its own:
- No workspace: the app returns to New workspace with the submitted draft and checkout
selection, and shows the error the way that screen shows its own submit errors.
- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding
the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's
own; the retry creates the agent in the kept workspace.
- An agent that exists or may exist, including a failed first prompt: creation completes
and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft
would create a second agent on Send.
Queued follow-ups and unsent composer text belong to a composer identity, so they move
with the creation: from the form to the creating agent, back to the form or the agent draft
on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial
prompt stays the form's or draft's input. The agent draft also carries the submitted
setup and context attachments. The host ignores `config.cwd` when a create request names a
workspace, so a draft's retry starts at the workspace root, not a nested fork cwd.
Adopted successful attempts release their payloads; a bounded cache of compact receipts
absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the
app has no resend loop. Pending views survive navigation and pane remount, not app
process death. Resource reservations are identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
-53
View File
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them.
For diff layout, two benchmarks share one corpus
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
shared document model with the web and native measurers replaced by counters, so it reports how
many strings each backend would have to shape, which is the cost that survives the move to canvas
or Skia:
```bash
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
```
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
React commit time for cold and warm toggles, then scroll frame times in both modes:
```bash
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
```
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
@@ -562,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## Dependency overrides and workspace tooling
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
`react-native`, …). npm does not apply those overrides to packages it places under a
workspace's own `node_modules`. When `expo-module-scripts` was declared in
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
where the overrides apply; workspace scripts still find the binary through the root
`node_modules/.bin`.
## Dependency signature audit
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
signature and, for packages published with provenance, fetches
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
for a year. An edge that cached it in the seconds before the registry attached the publish
attestation keeps serving a document with only the provenance bundle, and npm fails with:
```
npm error code EMISSINGSIGNATUREKEY
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
```
The same package's registry signature verified a moment earlier with the same keys, so the
key list is intact. The failure names one package, repeats only for that package, and depends
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+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.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
+2 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Client capability ownership
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

+10 -25
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
@@ -44,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches the latest available turns with a soft target of 40
projected items. Backward pages and prompt-jump windows also include whole turns, including
all currently available rows of an active run. There is no per-turn cap: a large run can
exceed the target. Local history mounting uses the same boundaries so revealing older rows
does not change the collapse state of a turn already displayed.
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
Reading history remains available on older hosts, whose pages can still split turns. Forward
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail of complete available turns. The item target is soft;
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Every canonical run owns a visible turn, including heartbeats and agent notifications.
Notifications delivered during a run stay inside it; batched notifications share the run
that starts or accepts them. Received messages retain sender attribution, while lifecycle
notifications remain visible events. Collapse, timing and locally mounted history share
these boundaries.
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
prompts, including schedule injections, remain hidden. One visible response can span several canonical
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
@@ -1,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-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
+4099 -2357
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.2",
"version": "0.13.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -131,7 +131,6 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -53,7 +53,6 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
@@ -987,7 +986,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -1004,10 +1003,8 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
// The complete turn arrives on reload; scrolling mounts its oldest rows
// without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
@@ -13,9 +13,7 @@ import {
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
sendDraftToQueue,
@@ -268,7 +266,9 @@ test.describe("Composer attachments", () => {
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test("composer and attachments stay available while new workspace agent is being created", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -298,8 +298,9 @@ test.describe("Composer attachments", () => {
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
await expectComposerEditable(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
agentCreatedDelay.release();
@@ -1,4 +1,12 @@
import { expectPickerSelected } from "../support/helpers/new-workspace";
import {
hideCreationLifecycle,
holdWorkspaceProvisioning,
reportUnknownPromptOutcome,
} from "../support/helpers/creation";
import type { createCreationScenario } from "../support/helpers/creation";
import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles";
import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
@@ -105,26 +113,38 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
for (const entry of [
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
name: "an agent draft",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openAgentDraft(),
button: "Send message",
expectWorkspace: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
},
{
name: "a new workspace",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openWorkspaceForm("local"),
button: "Create",
expectWorkspace: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.expectOneCreatedWorkspace(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
page,
}, testInfo) => {
const prompt = "Retry this agent after its startup fails.";
await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectFailedAgentDraft(prompt);
await entry.expectWorkspace(creation);
await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
await creation.resendFailedDraft();
await creation.expectPromptVisible(prompt);
await entry.expectWorkspace(creation);
await creation.expectAgentCount(1);
});
}
@@ -171,3 +191,356 @@ for (const entry of [
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
});
}
for (const isolation of ["local", "worktree"] as const) {
test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({
creation,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt.";
const followUp = "emit 1 coalesced agent stream updates for the queued follow-up.";
const draft = "Keep this unsent draft through adoption.";
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm(isolation);
await creation.submitPrompt(prompt, "Create");
const request = await provisioning.waitForRequest();
await test.step("the final agent panel paints before any host response", async () => {
expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await expect(
page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }),
).toBeVisible();
await creation.expectPromptVisible(prompt);
await creation.expectNormalAgentComposer();
await expectComposerModel(page, "Ten second stream");
await expectComposerMode(page, "Load test");
});
await test.step("a follow-up waits in the queue instead of reaching the agent", async () => {
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
expect(provisioning.agentPrompts()).toBe(0);
await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") });
});
await test.step("adoption keeps the same agent, draft and queue order", async () => {
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectUserMessagesInOrder([prompt, followUp]);
expect(provisioning.agentPrompts()).toBe(1);
await creation.expectComposerDraft(draft);
await creation.expectTimelineSettled();
await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") });
});
});
}
test("a held workspace on compact shows the final agent prompt and composer", async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await page.setViewportSize({ width: 390, height: 844 });
await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create");
const request = await provisioning.waitForRequest();
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectPromptVisible("Show my prompt before workspace provisioning.");
await creation.expectNormalAgentComposer();
await creation.expectAgentCannotBeStoppedYet();
await page.screenshot({ path: testInfo.outputPath("compact-held.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning.");
});
test("workspace creation recovers a lost response without replaying the first prompt", async ({
creation,
workspacePromptRetry,
}) => {
await creation.openWorkspaceForm("worktree");
workspacePromptRetry.holdAcknowledgement();
await creation.submitPrompt("Recover this workspace prompt once.", "Create");
await workspacePromptRetry.waitForDeliveredPrompt();
await creation.expectPromptVisible("Recover this workspace prompt once.");
await creation.evictAndReturnToDraft();
await workspacePromptRetry.disconnectAndReconnect();
await workspacePromptRetry.expectSameAgentAndMessage();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a pending workspace survives leaving and remounting before any daemon event", async ({
creation,
page,
}) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Keep this pending workspace across remount.", "Create");
await provisioning.waitForRequest();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a failed workspace returns to New workspace with the prompt and its error", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep my prompt when the workspace fails.";
await provisioningFailure.arm();
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", prompt);
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") });
});
test("a failed workspace preserves its non-default branch through resubmission", async ({
creation,
provisioningFailure,
page,
}) => {
const prompt = "Keep the selected worktree base when provisioning fails.";
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await creation.selectWorktreeBranch("recovery-base");
await provisioningFailure.arm();
await creation.submitPrompt(prompt, "Create");
const first = await provisioning.waitForRequest();
expect(first.source).toMatchObject({
kind: "worktree",
action: "branch-off",
refName: "refs/heads/recovery-base",
});
provisioning.release();
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
await expectPickerSelected(page, "recovery-base");
await creation.resubmitWorkspace();
const second = await provisioning.waitForRequests(2);
expect(second.source).toMatchObject({
kind: "worktree",
action: "branch-off",
refName: "refs/heads/recovery-base",
});
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace.";
const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up.";
const draft = "Keep this unsent text through the failed workspace.";
await provisioningFailure.arm();
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await provisioning.waitForRequest();
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
provisioning.release();
await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") });
});
await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => {
await creation.resubmitWorkspace();
await provisioning.waitForRequests(2);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") });
});
});
test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({
creation,
startup,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the retried agent.";
const followUp = "emit 1 coalesced agent stream updates for the retried follow-up.";
const draft = "Keep this unsent text through the failed agent start.";
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
await startup.fail();
await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => {
await creation.expectFailedAgentDraft(prompt);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") });
});
await test.step("retrying delivers the prompt, then the follow-up, once", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") });
});
});
test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({
creation,
lateStartup,
page,
}, testInfo) => {
const prompt = "Keep this fork's setup, image and history through the retry.";
await creation.openNestedAgentFork("worktree");
await creation.attachImage();
const startup = await lateStartup.arm();
await creation.submitPrompt(prompt, "Create");
await startup.fail();
await test.step("the agent draft keeps the fork's configuration and context", async () => {
await creation.expectFailedAgentDraft(prompt);
await expectComposerModel(page, "e2e-fast-stream");
await expectComposerMode(page, "Load test");
await creation.expectImageVisible();
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") });
});
// An agent created in an existing workspace starts at the workspace root: the host ignores
// config.cwd when the request names a workspace, so the nested cwd is not asserted here.
await test.step("retrying creates one agent in the worktree with that configuration and context", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" });
await creation.expectImageVisible();
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") });
});
});
test("a forked worktree whose workspace fails keeps its context through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep this fork's history when its workspace fails.";
await creation.openNestedAgentFork("worktree");
await provisioningFailure.arm();
await creation.submitPrompt(prompt, "Create");
await test.step("New workspace shows the prompt, its error and the fork's context", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") });
});
await test.step("resubmitting creates one agent with that context", async () => {
await creation.resubmitWorkspace();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") });
});
});
test("an unsupported host keeps the draft and asks for an update before creating anything", async ({
creation,
page,
}) => {
const host = await hideCreationLifecycle(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retain my unsupported host draft.", "Create");
await expect(
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
host.expectNoCreationRequest();
});
for (const isolation of ["local", "worktree"] as const) {
test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
const cwd = await creation.openNestedAgentFork(isolation);
await creation.attachImage();
await creation.submitPrompt("Keep this image and nested cwd.", "Create");
const request = await provisioning.waitForRequest();
expect(request.agent!.config?.cwd).toBe(cwd);
expect(request.agent!.images).toHaveLength(1);
await creation.expectPromptVisible("Keep this image and nested cwd.");
await creation.expectImageVisible();
await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectNestedAgentCwd();
await creation.expectImageVisible();
await creation.expectInitialPromptOnce("Keep this image and nested cwd.");
});
}
test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Finish creation after I close the tab.", "Create");
const request = await provisioning.waitForRequest();
await creation.expectPromptVisible("Finish creation after I close the tab.");
await creation.closePendingAgent(request.agent!.agentId!);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectInitialPromptOnce("Finish creation after I close the tab.");
});
test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({
creation,
page,
}) => {
await reportUnknownPromptOutcome(page);
await creation.openWorkspaceForm("local");
const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt.";
await creation.submitPrompt(prompt, "Create");
await creation.expectAssistantReply();
await creation.expectAgentNotWorking();
await creation.expectOneCreatedWorkspace();
await creation.expectInitialPromptOnce(prompt);
await creation.expectNoCreationRecovery();
await creation.expectAgentCount(1);
});
@@ -92,7 +92,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
});
}
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
page,
project,
client,
@@ -104,49 +104,34 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
expect((await client.fetchAgents()).entries).toHaveLength(0);
const workspaces = (await client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
await expectPromptOnce(client, first.id, prompt);
await expect(
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
).toHaveText(prompt);
expect(
(await client.fetchWorkspaces()).entries.filter(
(workspace) => workspace.projectId === project.projectId,
),
).toHaveLength(1);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForRequest();
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
)!.agent;
expect(second.workspaceId).toBe(first.workspaceId);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const agent = (await client.fetchAgents()).entries[0]!.agent;
expect(agent.workspaceId).toBe(project.workspaceId);
await client.waitForFinish(agent.id, 20_000);
await expectPromptOnce(client, agent.id, secondPrompt);
await expect(
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveText(secondPrompt);
await expect(
page
.getByTestId("user-message")
.filter({ visible: true })
.filter({ hasText: secondPrompt }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
});
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
@@ -1,7 +1,6 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
@@ -297,187 +296,8 @@ diffPerfDescribe("Diff canvas performance", () => {
await repo.cleanup().catch(() => undefined);
}
});
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
test.setTimeout(300_000);
const corpus = await createPullRequestCorpusRepository();
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: corpus.repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create wrap performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await instrumentDiffLatency(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
const openStartedAt = performance.now();
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
const open = {
readyMs: Math.round(performance.now() - openStartedAt),
...(await readDiffDocumentRenders(page)),
};
const toggles: WrapToggleMeasurement[] = [];
for (const name of [
"wrap on (cold)",
"wrap off (cold)",
"wrap on (warm)",
"wrap off (warm)",
]) {
toggles.push(await measureWrapToggle(page, name));
}
const unwrappedScroll = await continuouslyScrollCanvas(page);
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
const wrappedScroll = await continuouslyScrollCanvas(page);
const report = {
scenario: "wrap-toggle",
sourceFiles: corpus.fileCount,
sourceLines: corpus.lineCount,
open,
toggles,
scroll: {
unwrapped: summarizeScroll(unwrappedScroll),
wrapped: summarizeScroll(wrappedScroll),
},
};
await attachPerformanceReport(testInfo, report);
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
const [wrapOn, wrapOff] = toggles;
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
} finally {
await client.close().catch(() => undefined);
await corpus.repo.cleanup().catch(() => undefined);
}
});
});
interface WrapToggleMeasurement {
name: string;
/** Pointer down on the toolbar button to two painted frames after the document resized. */
latencyMs: number;
scrollHeightBefore: number;
scrollHeightAfter: number;
longestTaskMs: number;
longTaskTotalMs: number;
diffDocumentCommits: number;
diffDocumentRenderMs: number;
}
interface LongTask {
startTime: number;
duration: number;
}
interface DiffLatencyScope {
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
__PASEO_RESET_RENDER_PROFILE__?: () => void;
__PASEO_LONG_TASKS__?: LongTask[];
__PASEO_WRAP_TOGGLE_AT__?: number;
}
async function createPullRequestCorpusRepository() {
const files = pullRequestCorpus();
const repo = await createTempGitRepo("diff-wrap-perf-", {
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
});
for (const file of files) {
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
}
return {
repo,
fileCount: files.length,
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
};
}
async function instrumentDiffLatency(page: Page): Promise<void> {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
scope.__PASEO_LONG_TASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
});
}
async function readDiffDocumentRenders(
page: Page,
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === "DiffDocument",
);
return {
diffDocumentCommits: samples.length,
diffDocumentRenderMs: Math.round(
samples.reduce((total, sample) => total + sample.actualDuration, 0),
),
};
});
}
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
const scroll = page.getByTestId("git-diff-scroll");
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
await page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RESET_RENDER_PROFILE__?.();
scope.__PASEO_LONG_TASKS__ = [];
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
document.addEventListener(
"pointerdown",
() => {
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
},
{ capture: true, once: true },
);
});
await page.getByTestId("changes-toggle-wrap-lines").click();
const measured = await scroll.evaluate(async (element, before) => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const deadline = performance.now() + 240_000;
while (element.scrollHeight === before && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const doneAt = performance.now();
// Long task entries are delivered after the task ends; give the observer a turn.
await new Promise((resolve) => setTimeout(resolve, 50));
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
);
return {
latencyMs: Math.round(doneAt - startedAt),
scrollHeightAfter: element.scrollHeight,
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
};
}, scrollHeightBefore);
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
}
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
const { frameSignatures: _frameSignatures, ...summary } = report;
return summary;
}
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
@@ -1,7 +1,8 @@
import { observeCreationRequests } from "../support/helpers/creation";
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
fillComposerDraft,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
@@ -95,6 +96,7 @@ test.describe("New workspace composer draft", () => {
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
const requests = await observeCreationRequests(page);
try {
await gotoAppShell(page);
@@ -134,6 +136,9 @@ test.describe("New workspace composer draft", () => {
}, DRAFT);
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
// Navigation now precedes creation; wait for success before checking consumed input.
await expect.poll(requests.workspaceRequests).toBe(1);
await requests.settled();
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceDraft(page, "");
} finally {
@@ -142,10 +147,11 @@ test.describe("New workspace composer draft", () => {
});
});
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
@@ -157,25 +163,23 @@ test("fullscreen creation returns inline and keeps submission locked when reopen
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => {
await test.step("submission opens the normal agent panel inline", async () => {
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
});
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
await fillComposerDraft(page, followUpDraft);
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).toBeEditable();
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
@@ -192,11 +192,16 @@ test.describe("New Workspace dictation submit", () => {
await harness.waitForCreateRequest();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(composer).not.toBeEditable();
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
TRANSCRIPT,
);
await expect(composer).toHaveValue("");
await expect(composer).toBeEditable();
harness.failWorkspaceCreation();
await expect(page).toHaveURL(/\/new\?/);
await expect(composer).toBeEditable();
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
}
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
test("opens the final agent tab before agent creation resolves", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(draftTabs).toHaveCount(0);
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0);
).toHaveCount(1, { timeout: 30_000 });
agentCreatedDelay.release();
await expect(
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
@@ -11,11 +12,13 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
openAgentTimeline,
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
} 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,
}) => {
test.setTimeout(120_000);
@@ -55,8 +58,22 @@ test("loads history across browser reconnection while retaining old-daemon conne
};
try {
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
@@ -72,15 +89,34 @@ test("loads history across browser reconnection while retaining old-daemon conne
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("reconnect the browser and reload the old daemon history", async () => {
observationSockets.clear();
await page.reload();
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expect.poll(() => observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
@@ -94,6 +130,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
@@ -1,4 +1,3 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -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 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 type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
},
},
},
});
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
@@ -40,10 +19,7 @@ async function seedAnsweredAgent(title: string) {
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(
agent.agentId,
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.waitForFinish(agent.agentId, 30_000);
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("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expandAndCollapseLongPrompt(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Edited 1 file/);
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
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
@@ -148,9 +57,6 @@ test.describe("Turn collapse", () => {
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
@@ -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();
}
});
@@ -11,10 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -108,83 +105,6 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
+103 -27
View File
@@ -11,27 +11,24 @@ import {
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
interface StartupGate {
release(): Promise<void>;
fail(): Promise<void>;
dispose(): Promise<void>;
}
/** Holds every later workspace agent's startup until it is released or failed once. */
async function installStartupGate(): Promise<StartupGate> {
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
@@ -50,17 +47,88 @@ export default function contribute(server) {
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
return {
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
async dispose() {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
},
};
}
export const test = base.extend<{
startup: StartupGate;
/** A startup gate installed mid-test, after agents that must start normally. */
lateStartup: { arm(): Promise<StartupGate> };
promptRejection: void;
/** Fails the next workspace request once it is armed. */
provisioningFailure: { arm(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
workspacePromptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const gate = await installStartupGate();
try {
await provide(gate);
} finally {
await gate.dispose();
}
},
lateStartup: async ({ page }, provide) => {
void page;
const gates: StartupGate[] = [];
try {
await provide({
arm: async () => {
const gate = await installStartupGate();
gates.push(gate);
return gate;
},
});
} finally {
for (const gate of gates) await gate.dispose();
}
},
provisioningFailure: async ({ page, creation }, provide) => {
void page;
void creation;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const previous = await client.getDaemonConfig();
try {
const failure = path.join(plugin.directory, "provisioning-failure.txt");
// Fails one workspace request per arm, so a resubmission can succeed.
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { rm, stat } from "node:fs/promises";
export default function contribute(server) {
return server.before("workspace.create", async ({ request }) => {
const failure = ${JSON.stringify(failure)};
if (!(await stat(failure).catch(() => null))) return request;
await rm(failure, { force: true });
throw new Error("Provisioning outcome is uncertain for test");
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
await provide({ arm: () => writeFile(failure, "fail") });
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
@@ -114,6 +182,14 @@ export default function contribute(server) {
gate.restore();
}
},
workspacePromptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate, "workspace"));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
+334 -13
View File
@@ -1,12 +1,17 @@
import { awaitAssistantMessage } from "./agent-stream";
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer";
import { attachComposerTestImage, fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
connectNewWorkspaceDaemonClient,
openNewWorkspaceComposer,
openStartingRefPicker,
startingRefRow,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "./new-workspace";
@@ -44,6 +49,43 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
return { waitForRequest: () => request };
}
/** Hold the creation request before the daemon can reserve or provision anything. */
/** Holds each workspace request with an agent until `release` sends the held requests. */
export async function holdWorkspaceProvisioning(page: Page) {
const frames = await loadSessionMessageReaders();
let held: Array<() => void> = [];
const requests: WorkspaceCreateRequest[] = [];
let agentPrompts = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "send_agent_message_request") agentPrompts++;
if (message?.type === "workspace.create.request" && message.agent) {
held.push(() => server.send(frame));
requests.push(message);
return;
}
server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
async function waitForRequests(count: number) {
await expect.poll(() => requests.length).toBe(count);
return requests[count - 1]!;
}
return {
waitForRequest: () => waitForRequests(1),
waitForRequests,
release: () => {
const sending = held;
held = [];
for (const send of sending) send();
},
agentPrompts: () => agentPrompts,
};
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled();
@@ -59,9 +101,11 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
let workspaceRequests = 0;
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "workspace.create.request") workspaceRequests++;
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
@@ -84,6 +128,7 @@ export async function observeCreationRequests(page: Page) {
});
});
return {
workspaceRequests: () => workspaceRequests,
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
@@ -138,18 +183,101 @@ export async function retryNextAgentCreation(page: Page) {
};
}
function agentComposer(page: Page) {
// Retained workspaces keep hidden composers mounted beside the focused one.
return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true });
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
const project = await seedWorkspace({
repoPrefix: "creation-idempotency-",
repo: {
files: [{ path: "nested/context.txt", content: "Nested agent working directory" }],
branches: ["recovery-base"],
},
});
let workspaceId = project.workspaceId;
const evictionWorkspaceIds = new Set<string>();
const sourceClients: Array<{ close(): Promise<void> }> = [];
return {
cleanup: project.cleanup,
cleanup: async () => {
for (const client of sourceClients) await client.close();
await project.cleanup();
},
async openNestedAgentFork(isolation: "local" | "worktree") {
const cwd = `${project.repoPath}/nested`;
const sourceClient = await connectNewWorkspaceDaemonClient();
sourceClients.push(sourceClient);
const source = await sourceClient.createWorkspace({
source: { kind: "directory", path: project.repoPath },
agent: {
config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd },
initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.",
},
});
if (!source.agent || !source.workspace)
throw new Error(source.error ?? "Missing nested source agent");
evictionWorkspaceIds.add(source.workspace.id);
await project.client.waitForFinish(source.agent.id, 20_000);
await gotoWorkspace(page, source.workspace.id);
await awaitAssistantMessage(page);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await selectWorkspaceIsolation(page, isolation);
return cwd;
},
attachImage: () => attachComposerTestImage(page),
async expectImageVisible() {
await expect(
page
.getByRole("button", { name: "Open image attachment", exact: true })
.filter({ visible: true }),
).toHaveCount(1);
},
async expectInitialPromptOnce(prompt: string) {
const agents = (await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
);
expect(agents).toHaveLength(1);
const reader = await connectNewWorkspaceDaemonClient();
try {
const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id);
expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1);
expect(
timeline.entries.filter(
({ item }) => item.type === "user_message" && item.text === prompt,
),
).toHaveLength(1);
} finally {
await reader.close();
}
},
async expectNestedAgentCwd() {
const workspace = (await project.client.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspaceId,
)!;
const nestedAgent = (await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)!.agent;
expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`);
},
async closePendingAgent(agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover();
await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0);
},
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
},
async selectWorktreeBranch(name: string) {
await openStartingRefPicker(page);
await startingRefRow(page, `${name}, local branch`).click();
},
async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
@@ -165,6 +293,74 @@ export async function createCreationScenario(page: Page) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectNormalAgentComposer() {
await expect(agentComposer(page)).toBeEditable();
// Provisioning belongs to the workspace header and sidebar, not the conversation.
await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0);
},
async expectAgentCannotBeStoppedYet() {
await expect(
page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }),
).toBeDisabled();
},
async queueFollowUp(prompt: string) {
await agentComposer(page).fill(prompt);
await agentComposer(page).press("Enter");
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toBeVisible();
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async expectQueuedFollowUp(prompt: string) {
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toHaveCount(1);
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async resubmitWorkspace() {
await page.getByRole("button", { name: "Create", exact: true }).click();
},
async expectAgentConfig(config: { model: string; modeId: string }) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
)!.agent;
expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config);
},
async typeComposerDraft(text: string) {
await agentComposer(page).fill(text);
// The composer publishes typed text to its draft after the next paint.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))),
);
},
async expectComposerDraft(text: string) {
await expect(agentComposer(page)).toHaveValue(text);
},
async expectAgentNotWorking() {
await expect(
page.getByTestId("turn-working-indicator").filter({ visible: true }),
).toHaveCount(0);
},
async expectTimelineSettled() {
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
},
async expectUserMessagesInOrder(prompts: string[]) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
);
const reader = await connectNewWorkspaceDaemonClient();
try {
await expect
.poll(async () =>
(await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) =>
item.type === "user_message" ? [item.text] : [],
),
)
.toEqual(prompts);
} finally {
await reader.close();
}
},
async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true });
@@ -175,7 +371,7 @@ export async function createCreationScenario(page: Page) {
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
@@ -224,15 +420,57 @@ export async function createCreationScenario(page: Page) {
.first(),
).toBeVisible();
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
async expectFailedAgentDraft(prompt: string) {
await requests.settled();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
await expect(
page
.getByText(/Creation startup failed for test/)
.filter({ visible: true })
.first(),
).toBeVisible();
await expect(agentComposer(page)).toHaveValue(prompt);
await this.expectNoCreationRecovery();
},
async resendFailedDraft() {
await page.getByRole("button", { name: "Send message", exact: true }).click();
},
async expectReturnedToNewWorkspace(prompt: string, error: string) {
await requests.settled();
await expect(page).toHaveURL(/\/new\?/);
await expect(agentComposer(page)).toHaveValue(prompt);
await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible();
await this.expectNoCreationRecovery();
expect(requests.workspaceRequests()).toBe(1);
expect(
(await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
),
).toHaveLength(0);
},
async expectNoCreationRecovery() {
await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount(
0,
);
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -265,8 +503,11 @@ export async function createCreationScenario(page: Page) {
.toBe(title);
},
async evictAndReturnToDraft() {
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.locator(
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
)
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
@@ -276,6 +517,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -296,15 +538,18 @@ export async function createCreationScenario(page: Page) {
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
kind: "agent" | "workspace" = "agent",
) {
const requestType = `${kind}.create.request`;
const responseType = `${kind}.create.response`;
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response");
gate.holdNextServerMessage(responseType);
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
await gate.waitForHeldServerMessage(responseType);
firstMessage = gate.getClientRequests(requestType).at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
@@ -315,13 +560,89 @@ export function createPromptRetryScenario(
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
expect(gate.getClientRequestCount(requestType)).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
export async function hideCreationLifecycle(page: Page) {
const frames = await loadSessionMessageReaders();
let requests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (frames.client(frame)?.type === "workspace.create.request") requests++;
server.send(frame);
});
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "status" &&
message.payload.status === "server_info" &&
typeof message.payload.features === "object" &&
message.payload.features !== null
) {
browser.send(
JSON.stringify({
type: "session",
message: {
...message,
payload: {
...message.payload,
features: { ...message.payload.features, creationLifecycle: false },
},
},
}),
);
return;
}
browser.send(frame);
});
});
return { expectNoCreationRequest: () => expect(requests).toBe(0) };
}
/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */
export async function reportUnknownPromptOutcome(page: Page) {
const frames = await loadSessionMessageReaders();
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => server.send(frame));
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "workspace.create.response" &&
message.payload.agent &&
message.payload.creation
) {
const error = "Initial prompt outcome is uncertain for test";
browser.send(
JSON.stringify({
...JSON.parse(frame.toString()),
message: {
...message,
payload: {
...message.payload,
error,
creation: {
...message.payload.creation,
phase: "failed",
error,
outcomeUnknown: true,
},
},
},
}),
);
return;
}
browser.send(frame);
});
});
}
@@ -19,9 +19,6 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
// Config updates before provider refresh finishes and dismisses the add sheet.
// Resizing or interacting with the model list must wait for that save to finish.
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchAgentTimeline"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -149,11 +149,7 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -1,47 +1,12 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.2",
"version": "0.13.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -61,6 +61,7 @@
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
@@ -76,8 +77,8 @@
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
+6 -2
View File
@@ -1,3 +1,4 @@
import { useAgentCreation } from "@/workspace-creation";
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
@@ -33,6 +34,7 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const creation = useAgentCreation(context.serverId, target.agentId);
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -49,8 +51,10 @@ export function useAgentDescriptor(
};
}),
);
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
const provider = creation ? creation.provider : descriptorState.provider;
const title = creation ? creation.text : descriptorState.title;
const icon = useProviderIcon(provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
}
export function buildAgentDescriptor(input: {
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByItemId: timingFor(assistant.id),
timingByAssistantId: timingFor(assistant.id),
}),
};
}
@@ -1,5 +1,4 @@
import type { StreamItem } from "@/types/stream";
import { continuesTurn } from "./turn-membership";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
@@ -33,7 +32,7 @@ export function findMountedWindowStart(input: {
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByItemId: timingFor(...(input.timingIds ?? [])),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
});
it("recomputes cached history layout when only the live boundary turn changes", () => {
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);
+26 -29
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean;
history: StreamItem[];
liveHead: StreamItem[];
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
items: StreamItem[];
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete";
}
interface TurnFooterSource {
item: StreamItem;
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
@@ -67,26 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByItemId.get(input.item.id),
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function findTurnFooterSource(input: {
function findLatestAssistantInResponse(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): TurnFooterSource | null {
const last = input.items[input.startIndex];
const toolFallback: TurnFooterSource | null =
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
@@ -100,7 +97,7 @@ function findTurnFooterSource(input: {
) {
const item = items[index];
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return toolFallback;
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
@@ -114,7 +111,7 @@ function findTurnFooterSource(input: {
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return toolFallback;
return null;
}
items = input.boundaryAboveItems;
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const source = findTurnFooterSource({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!source) {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
index: number;
item: StreamItem;
belowItem: StreamItem | null;
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
return null;
}
const source = findTurnFooterSource({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -309,7 +306,7 @@ function layoutSegmentItem(
index,
item,
belowItem,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
@@ -378,7 +375,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({
strategy: input.strategy,
items: input.history,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
@@ -398,7 +395,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
items: input.liveHead,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
+9 -9
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
});
it("maps completed turn timing to assistant ids across committed history and live head", () => {
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byItemId.get("live-a")).toEqual({
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byItemId.get("a1")).toEqual({
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.turnTiming.byItemId.size).toBe(0);
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
it.each(["web", "native"] as const)(
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
expect(second.history).toBe(first.history);
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
expect(first.history).toHaveLength(20);
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
expect(first.turnTiming.byItemId.get("a38")).toEqual({
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp,
durationMs: 1000,
});
@@ -588,129 +588,3 @@ describe("timeline presentation", () => {
expect(projectTimelineItems([source], () => [])).toEqual([]);
});
});
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
toolCall("two", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const result = present({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
});
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 { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
@@ -17,11 +16,9 @@ interface PresentationInput {
head: StreamItem[];
transform: TimelineItemTransform | undefined;
display: ChatDisplay;
autoExpandReasoning?: boolean;
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
activeTurnId?: string | null;
}
export interface StreamPresentation extends ToolCallDetailProjection {
@@ -58,7 +55,6 @@ export function createStreamPresentation() {
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedAutoExpandReasoning: boolean | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
let collapsedSource: StreamItem[] | undefined;
@@ -67,22 +63,6 @@ export function createStreamPresentation() {
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] {
if (item.kind === "tool_call" && item.payload.source === "agent") {
const message = item.payload.data.agentMessage;
if (message?.event === "message") {
return [
{
kind: "user_message",
id: item.id,
turnId: item.turnId,
timelineCursor: item.timelineCursor,
timestamp: item.timestamp,
text: message.text,
sender: message.sender,
},
];
}
}
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
@@ -95,14 +75,7 @@ export function createStreamPresentation() {
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const isLatestTurnOpen =
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
if (
!collapsed ||
collapsedSource !== projection.tail ||
@@ -120,20 +93,7 @@ export function createStreamPresentation() {
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
});
return {
...projection,
tail,
turns,
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
};
return { ...projection, tail, turns };
}
return (input: PresentationInput): StreamPresentation => {
@@ -151,15 +111,10 @@ export function createStreamPresentation() {
const blocks = presentRows(item);
head.push(...blocks);
}
if (
preparedTail !== historyRows ||
preparedLevel !== level ||
preparedAutoExpandReasoning !== input.autoExpandReasoning
) {
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
if (preparedTail !== historyRows || preparedLevel !== level) {
preparedHistory = prepareToolCallHistory(level, historyRows);
preparedTail = historyRows;
preparedLevel = level;
preparedAutoExpandReasoning = input.autoExpandReasoning;
}
return collapse(
projectToolCallDetailLevel({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2");
});
it("keeps copied content within the canonical turn without a visible prompt", () => {
it("collects copy content across adjacent turns without a visible prompt", () => {
const chronological: StreamItem[] = [
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
{ ...toolCall("tool", 2), turnId: "turn-2" },
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
items: chronological,
startIndex: 2,
}),
).toBe("second turn");
).toBe("first turn\n\nsecond turn");
});
it("returns undefined neighbor when index would be out of bounds", () => {
@@ -7,7 +7,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
import type { StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
@@ -131,79 +131,6 @@ describe("createWebStreamStrategy", () => {
vi.restoreAllMocks();
});
it.each(["liveHead", "historyMounted"] as const)(
"keeps one rendered tool row after late prior-turn updates in %s",
(lane) => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: lane === "historyMounted",
hasLiveHead: lane === "liveHead",
},
renderers: createRenderers(() => {}),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
let items: StreamItem[] = [];
const updates = [
{ callId: "background", turnId: "old", status: "running" },
{ callId: "exec", turnId: "current", status: "running" },
{ callId: "background", turnId: "old", status: "completed" },
{ callId: "exec", turnId: "current", status: "completed" },
] as const;
for (const [index, update] of updates.entries()) {
items = reduceStreamUpdate(
items,
{
type: "timeline",
provider: "codex",
turnId: update.turnId,
item: {
type: "tool_call",
callId: update.callId,
name: "exec_command",
status: update.status,
error: null,
detail: { type: "shell", command: update.callId },
},
},
new Date(index),
);
act(() =>
root?.render(
strategy.render({
...input,
segments: { ...input.segments, [lane]: items },
}),
),
);
}
const rows = [...container.querySelectorAll("[data-history-row-id]")];
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
"agent_tool_turn:old/background",
"agent_tool_turn:current/exec",
]);
},
);
it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
isTurnActive: true,
history: model.history,
liveHead: model.segments.liveHead,
timingByItemId: model.turnTiming.byItemId,
timingByAssistantId: model.turnTiming.byAssistantId,
});
}
}
@@ -48,41 +48,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
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", () => {
const result = collapse([
user("u1"),
@@ -98,6 +63,7 @@ describe("collapseTurns", () => {
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
@@ -155,34 +121,6 @@ describe("collapseTurns", () => {
expect(result.turnsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
Object.assign(item, { turnId: "first" }),
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
it("reveals every raw call inside an expanded turn", () => {
const calls = [shell("one"), shell("two")];
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
it("counts every call a tool-call group row stands for", () => {
const group = [shell("c1"), shell("c2"), shell("c3")];
const result = collapse([user("u1"), group[0]!, reply("final")], {
@@ -1,18 +1,13 @@
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
import {
getStreamItemMessageId,
type StreamItem,
type ThoughtItem,
type ToolCallItem,
} from "@/types/stream";
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The 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;
expanded: boolean;
summary: TurnSummary;
@@ -33,7 +28,7 @@ export interface CollapseTurnsInput {
isLatestTurnOpen: boolean;
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
@@ -44,21 +39,17 @@ function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: (ToolCallItem | ThoughtItem)[] = [];
const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) {
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
if (item.kind === "assistant_message") messageCount += 1;
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 {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
}
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
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Reasoning is revealed only when the turn opens.
* before its reply. Thoughts fold with the turn but are not counted.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
@@ -86,47 +77,28 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (
!host ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
!isClosed ||
replyIndex <= runStart
) {
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
if (!hasWork(summary)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
? [...(input.getGroupedCalls(item.id) ?? [item])]
: [item],
)
: folded;
for (const item of revealed) {
if (staysVisibleWhenCollapsed(item)) continue;
for (const item of folded) {
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
}
output.push(
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
);
if (expanded) output.push(...folded);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (
!(index === 0 && isTurnTrigger(tail[index]!)) &&
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
)
continue;
if (tail[index]?.kind !== "user_message") continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
@@ -142,10 +114,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
tail: output.length === tail.length ? tail : output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
@@ -1,4 +1,4 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { ExpandableBadge } from "@/components/message";
@@ -17,7 +17,19 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
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(
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
isTurnActive,
history: model.segments.historyMounted,
liveHead: model.segments.liveHead,
timingByItemId: model.turnTiming.byItemId,
timingByAssistantId: model.turnTiming.byAssistantId,
}),
};
}
@@ -67,24 +67,6 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
}
describe("canonical turn membership", () => {
it("uses trigger fallback for missing IDs without pretending to recover batching", () => {
const previous = assistant("reply", 1, "first");
const trigger = runningTool("heartbeat", 2, "first");
if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent")
throw new Error("Expected agent tool");
trigger.payload.data.name = "heartbeat";
expect(continuesTurn(previous, trigger)).toBe(true);
delete trigger.turnId;
expect(continuesTurn(previous, trigger)).toBe(false);
trigger.payload.data.name = "agent_message";
trigger.payload.data.agentMessage = {
event: "finished",
sender: { id: "worker" },
text: "Done",
};
expect(continuesTurn(previous, trigger)).toBe(false);
});
it("stops tagged traversal at every mismatching turn ID", () => {
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
false,
@@ -94,7 +76,7 @@ describe("canonical turn membership", () => {
);
});
it("renders a separate footer for each canonical turn without visible prompts", () => {
it("renders adjacent turns without visible prompts as one response", () => {
const items = [
user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"),
@@ -106,11 +88,7 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual([
"first reply",
"heartbeat-1-reply",
"heartbeat-2-reply",
]);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
@@ -145,7 +123,7 @@ describe("canonical turn membership", () => {
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
completedAt: at(9),
durationMs: 8000,
});
@@ -1,21 +1,24 @@
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
import type { StreamItem } from "@/types/stream";
/**
* Canonical turn IDs take precedence. Timelines without them retain the legacy
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
* user-message boundary rule so old daemons and persisted rows need no rewrite.
*/
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
if (previous.turnId !== undefined && next.turnId !== undefined) {
return previous.turnId === next.turnId;
}
return next.kind !== "user_message";
}
/**
* Every canonical turn owns its response, including system-triggered runs.
* A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
*/
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTurn(previous, next);
return continuesTurn(previous, next) || next.kind !== "user_message";
}
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -34,12 +37,3 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
return turnId !== null && item.turnId === turnId;
}
/** Inputs that start a turn when older history has no canonical turn IDs. */
export function isTurnTrigger(item: StreamItem): boolean {
return isTimelineTurnTrigger(
item.kind === "tool_call" && item.payload.source === "agent"
? { type: item.kind, ...item.payload.data }
: { type: item.kind },
);
}
+11 -45
View File
@@ -524,10 +524,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem,
display: chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive,
activeTurnId: effectiveTurnPresentation.turnId,
}),
[
presentStream,
@@ -535,10 +533,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
effectiveStreamHead,
transformTimelineItem,
chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive,
effectiveTurnPresentation.turnId,
],
);
const {
@@ -580,12 +576,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByItemId: baseRenderModel.turnTiming.byItemId,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byItemId,
baseRenderModel.turnTiming.byAssistantId,
isTurnActive,
streamRenderStrategy,
],
@@ -726,7 +722,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId={agentId}
messageId={item.messageId}
message={item.text}
sender={item.sender}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
@@ -873,15 +868,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
presentation.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(
layoutItem: StreamLayoutItem,
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
) => {
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = getToolCallGroup(item.id);
if (!group) {
return item.kind === "thought"
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.run.id);
return (
@@ -894,21 +884,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{expanded
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{call.kind === "thought" ? (
<ThoughtSlot
itemId={call.id}
text={call.text}
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
{renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)}
</React.Fragment>
))
@@ -918,8 +897,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
[
expandedToolCallGroupIds,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
@@ -937,20 +914,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderAssistantMessageItem(layoutItem, item);
case "thought":
return renderToolCallItem(layoutItem, item);
return renderThoughtItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -980,9 +947,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
);
@@ -70,17 +70,6 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
}
describe("findMountedWindowStart", () => {
it("mounts complete autonomous runs and keeps same-run steers together", () => {
const items = [
{ ...userMessage("human", 1), turnId: "first" },
{ ...assistantMessage("reply", 2), turnId: "first" },
{ ...toolCall("wake", 3), turnId: "autonomous" },
{ ...userMessage("steer", 4), turnId: "autonomous" },
{ ...assistantMessage("done", 5), turnId: "autonomous" },
];
expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2);
});
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
+4 -3
View File
@@ -3,10 +3,11 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
* expanded tool cards stay in place, with a gap between their border and the rail.
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
* hangs from that center so opened rows read as children of the line that opened them.
*/
const RAIL_LEFT = 1;
const RAIL_LEFT = 7;
const RAIL_WIDTH = 1;
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 { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
@@ -90,7 +89,6 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
messageId?: string;
@@ -389,7 +387,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
sender,
serverId,
agentId,
messageId,
@@ -476,45 +473,42 @@ export const UserMessage = memo(function UserMessage({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
<MessagePreview>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<UserMessageImagePill
key={image.id}
image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
{message}
</Text>
) : null}
</MessagePreview>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<UserMessageImagePill
key={image.id}
image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
{message}
</Text>
) : null}
</View>
{hasText ? (
<View
@@ -547,7 +541,7 @@ export const UserMessage = memo(function UserMessage({
});
interface AssistantTurnFooterProps {
getContent?: () => string;
getContent: () => string;
completedAt?: Date;
durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
@@ -647,12 +641,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
@@ -1,4 +1,3 @@
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
@@ -43,7 +42,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
...planTimelineTailFetch(),
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
@@ -2705,12 +2705,8 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
width: 24,
height: 24,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -17,7 +17,6 @@ import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import { densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
@@ -71,11 +70,13 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
export interface SidebarWorkspaceMenuProps {
@@ -438,18 +439,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
padding: 2,
borderRadius: 4,
marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: { xs: -14, md: -7 },
marginRight: -7,
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -1,3 +1,5 @@
import { useWorkspaceCreation } from "@/workspace-creation";
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
@@ -58,7 +60,16 @@ interface SidebarWorkspaceRowProps {
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
export function SidebarWorkspaceRow(props: SidebarWorkspaceRowProps) {
const creation = useWorkspaceCreation(props.workspace.serverId, props.workspace.workspaceId);
if (creation && !creation.workspaceObserved)
return (
<CreatingWorkspaceRow attempt={creation} onPress={props.onPress} selected={props.selected} />
);
return <ReadySidebarWorkspaceRow {...props} />;
}
function ReadySidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
+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 type { Theme } from "@/styles/theme";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
surface3: (theme) => ({ color: theme.colors.surface3 }),
surface0: (theme) => ({ color: theme.colors.surface0 }),
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
};
@@ -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] },
}));
+82
View File
@@ -296,6 +296,7 @@ describe("cancelComposerAgent", () => {
function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] };
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
@@ -309,6 +310,7 @@ describe("cancelComposerAgent", () => {
},
},
agentId: "agent",
isAgentPublished: true,
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
@@ -340,6 +342,13 @@ describe("cancelComposerAgent", () => {
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing while the host has not published the agent", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentPublished: false });
expect(result).toBeNull();
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when the agent is already being canceled", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
@@ -419,6 +428,29 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("queues a prompt behind earlier ones without sending while the agent is not ready", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const queued: QueuedComposerMessage = { id: "later", text: "later", attachments: [] };
const queue = createFakeQueue(new Map([["agent", [queued]]]));
const outcome = await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "next follow-up",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: false,
queue,
});
expect(outcome).toBe("queued");
expect(client.calls).toEqual([]);
expect(stream.tail.get("agent") ?? []).toEqual([]);
expect(queue.read("agent").map((item) => item.text)).toEqual(["later", "next follow-up"]);
});
it("forwards the configured active-turn intent without provider capability checks", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
@@ -430,6 +462,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
@@ -446,6 +480,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
activeTurnId: "turn-1",
});
@@ -458,6 +494,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: legacy,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
@@ -476,6 +514,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(rejection);
@@ -496,6 +536,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
@@ -519,6 +561,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
});
@@ -540,6 +584,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
}),
).rejects.toBe(transportError);
@@ -560,6 +606,8 @@ describe("dispatchComposerAgentMessage", () => {
],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls).toHaveLength(1);
@@ -606,6 +654,8 @@ describe("dispatchComposerAgentMessage", () => {
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0].options.attachments).toEqual([
@@ -639,6 +689,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(stream.head.get("agent")).toHaveLength(2);
@@ -656,6 +708,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options).toMatchObject({
@@ -676,6 +730,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [review],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -694,6 +750,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([
@@ -796,6 +854,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -814,6 +873,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -825,6 +885,27 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("leaves the queue untouched while the agent is not ready", async () => {
const queued: QueuedComposerMessage[] = [
{ id: "msg-1", text: "first", attachments: [] },
{ id: "msg-2", text: "second", attachments: [] },
];
const queue = createFakeQueue(new Map([["agent", queued]]));
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: false,
messageId: "msg-2",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "waiting" });
expect(submitted).toEqual([]);
expect(queue.state.get("agent")).toBe(queued);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue(
new Map([
@@ -839,6 +920,7 @@ describe("sendQueuedComposerMessageNow", () => {
);
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async () => {
+16 -2
View File
@@ -170,13 +170,14 @@ export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(in
export interface CancelComposerAgentInput {
client: ComposerCancelClient | null;
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null {
if (!input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isAgentPublished || !input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isConnected || !input.client) return null;
try {
return Promise.resolve(input.client.cancelAgent(input.agentId));
@@ -188,6 +189,9 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
/** See isAgentReadyForPrompts. Prompts for an agent that is not ready wait in its queue. */
isAgentReady: boolean;
queue: QueueWriter;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -201,7 +205,12 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
): Promise<"sent" | "queued"> {
if (!input.isAgentReady) {
// A creating agent takes prompts after its initial one; this waits behind it in the queue.
queueComposerMessage(input);
return "queued";
}
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
@@ -226,6 +235,7 @@ export async function dispatchComposerAgentMessage(
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId);
return "sent";
} catch (error) {
input.submission.reject(input.agentId, clientMessageId);
throw error;
@@ -293,6 +303,8 @@ export function editQueuedComposerMessage(
export interface SendQueuedComposerMessageNowInput {
agentId: string;
/** See DispatchComposerAgentMessageInput. A queued prompt stays in place until then. */
isAgentReady: boolean;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
@@ -301,6 +313,7 @@ export interface SendQueuedComposerMessageNowInput {
export type SendQueuedComposerMessageNowResult =
| { status: "missing" }
| { status: "waiting" }
| { status: "submitted" }
| { status: "failed"; errorMessage: string };
@@ -309,6 +322,7 @@ export async function sendQueuedComposerMessageNow(
): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" };
if (!input.isAgentReady) return { status: "waiting" };
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
@@ -35,6 +35,7 @@ import {
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { useSessionStore } from "@/stores/session-store";
import { useAgentCreation } from "@/workspace-creation";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -420,6 +421,56 @@ function selectAgentControlsSlice(
};
}
/** A creating agent shows its submitted configuration, read-only, until the host publishes it. */
function useAgentControlsSource(serverId: string, agentId: string) {
const publishedAgent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
);
const creation = useAgentCreation(serverId, agentId);
const hasClient = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const agent = useMemo<AgentControlsSlice>(() => {
if (publishedAgent || !creation) return publishedAgent;
return {
provider: creation.provider,
// The destination is not known yet; never stand in the source directory for it.
cwd: null,
runtimeModelId: null,
model: creation.config.model,
features: undefined,
runtimeThinkingOptionId: null,
thinkingOptionId: creation.config.thinkingOptionId,
lastUsage: undefined,
};
}, [creation, publishedAgent]);
return {
agent,
isPublished: publishedAgent !== null,
canEdit: hasClient && publishedAgent !== null,
submittedModeId: creation?.config.modeId ?? null,
};
}
function ignoreModeSelection(): void {}
function resolveModeControl(input: {
live: AgentModeControlValue | null;
isPublished: boolean;
submittedModeId: string | null;
entry: ReturnType<typeof resolveSnapshotSelectedEntry>;
providerDefinitions: AgentProviderDefinition[];
}): AgentModeControlValue | null {
if (input.isPublished) return input.live;
if (input.entry?.status !== "ready" || !input.entry.modes?.length) return null;
return {
provider: input.entry.provider,
providerDefinitions: input.providerDefinitions,
modeOptions: input.entry.modes,
selectedModeId: input.submittedModeId,
onSelectMode: ignoreModeSelection,
disabled: true,
};
}
function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined,
@@ -1556,14 +1607,13 @@ export const AgentControls = memo(function AgentControls({
isCompactLayout,
}: AgentControlsProps) {
const { updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
const { agent, isPublished, canEdit, submittedModeId } = useAgentControlsSource(
serverId,
agentId,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const modeControl = useLiveAgentModeControl(serverId, agentId);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const liveModeControl = useLiveAgentModeControl(serverId, agentId);
const {
entries: snapshotEntries,
@@ -1586,6 +1636,26 @@ export const AgentControls = memo(function AgentControls({
[agent?.provider, snapshotEntries],
);
const modeControl = useMemo(
() =>
resolveModeControl({
live: liveModeControl,
isPublished,
submittedModeId,
entry: snapshotSelectedEntry,
providerDefinitions: agentProviderDefinitions,
}),
[
agentProviderDefinitions,
isPublished,
liveModeControl,
snapshotSelectedEntry,
submittedModeId,
],
);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const agentProviderModels = useMemo(
() => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
@@ -1769,7 +1839,7 @@ export const AgentControls = memo(function AgentControls({
const commandCenterRegistration = (
<AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`}
enabled={Boolean(client)}
enabled={canEdit}
controls={commandCenterControls}
/>
);
@@ -1814,7 +1884,7 @@ export const AgentControls = memo(function AgentControls({
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!client}
disabled={!canEdit}
modeControl={modeControl}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
@@ -63,6 +63,7 @@ export interface AgentInputDraft {
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
flush: () => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
@@ -172,6 +173,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey, textPublication],
);
const flushDraft = useCallback(() => textPublication.flush(), [textPublication]);
const replaceText = useCallback(
(nextText: string) => {
textPublication.cancel();
@@ -351,6 +354,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return {
textSource,
editText,
flush: flushDraft,
replaceText,
textReplacement,
attachments,
@@ -49,6 +49,7 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { transferComposerFollowUps } from "@/composer/submission/writer";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -184,8 +185,7 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
const result = await client.createAgent(options);
return {
agentId: result.id,
@@ -466,10 +466,6 @@ export function WorkspaceDraftAgentTab({
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
@@ -491,6 +487,7 @@ export function WorkspaceDraftAgentTab({
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
transferComposerFollowUps(serverId, tabId, result.id);
},
});
const turnPresentation = useMemo(
+31 -26
View File
@@ -58,6 +58,7 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
selectIsAgentPublished,
useSessionStore,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -88,9 +89,9 @@ import {
toggleForgeAttachmentFromPicker,
uploadFileAttachments,
type AttachmentPersister,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -123,7 +124,10 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
@@ -258,8 +262,8 @@ function resolveCheckoutRemoteUrl(
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
function buildCancelButtonStyle(canCancel: boolean, isCancellingAgent: boolean): object[] {
const disabled = !canCancel || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
@@ -1083,7 +1087,7 @@ interface ComposerCancelButtonProps {
buttonIconSize: number;
cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void;
isConnected: boolean;
canCancel: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
@@ -1093,7 +1097,7 @@ function ComposerCancelButton({
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isConnected,
canCancel,
isCancellingAgent,
agentInterruptKeys,
t,
@@ -1111,7 +1115,7 @@ function ComposerCancelButton({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
disabled={!canCancel || isCancellingAgent}
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
style={cancelButtonStyle}
@@ -1306,8 +1310,6 @@ function ComposerContentImpl({
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
@@ -1570,9 +1572,12 @@ function ComposerContentImpl({
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const session = useSessionStore.getState().sessions[serverId];
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
isAgentReady: isAgentReadyForPrompts(serverId, targetAgentId),
queue: createAgentMessageQueueWriter(serverId),
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -1583,10 +1588,7 @@ function ComposerContentImpl({
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
? (selectAgentTurnPresentation(session, targetAgentId).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
@@ -1620,13 +1622,7 @@ function ComposerContentImpl({
);
const hasAgent = agentState.status !== null;
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueWriter = useMemo(() => createAgentMessageQueueWriter(serverId), [serverId]);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
@@ -1896,6 +1892,10 @@ function ComposerContentImpl({
const cancellation = cancelComposerAgent({
client,
agentId: targetAgentId,
isAgentPublished: selectIsAgentPublished(
useSessionStore.getState().sessions[serverId],
targetAgentId,
),
isAgentRunning,
isCancellingAgent,
isConnected,
@@ -1952,6 +1952,7 @@ function ComposerContentImpl({
// Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
@@ -1962,7 +1963,7 @@ function ComposerContentImpl({
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, submitMessage, t],
[agentId, queueWriter, serverId, submitMessage, t],
);
const handleQueue = useCallback(
@@ -2010,9 +2011,14 @@ function ComposerContentImpl({
[agentId, recall, replaceUserInput, serverId],
);
const isAgentPublished = useSessionStore((state) =>
selectIsAgentPublished(state.sessions[serverId], agentId),
);
// A creating agent cannot be stopped until the host publishes it.
const canCancelAgent = isConnected && isAgentPublished;
const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
[isConnected, isCancellingAgent],
() => buildCancelButtonStyle(canCancelAgent, isCancellingAgent),
[canCancelAgent, isCancellingAgent],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
@@ -2029,7 +2035,7 @@ function ComposerContentImpl({
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
isConnected={isConnected}
canCancel={canCancelAgent}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
@@ -2039,9 +2045,9 @@ function ComposerContentImpl({
agentInterruptKeys,
buttonIconSize,
cancelButtonStyle,
canCancelAgent,
handleCancelAgent,
isCancellingAgent,
isConnected,
t,
],
);
@@ -2495,7 +2501,6 @@ function ComposerContentImpl({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+4 -34
View File
@@ -1,4 +1,3 @@
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -89,7 +88,6 @@ import {
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
@@ -98,11 +96,6 @@ import {
} from "./state";
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;
export interface AttachmentMenuItem {
@@ -174,7 +167,6 @@ export interface MessageInputProps {
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
* lives only in DEFAULT_CLIENT_SETTINGS. */
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -410,9 +402,6 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
sendShortcut: SendShortcut;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -439,21 +428,12 @@ function handleDesktopKeyPressImpl(
}
if (event.nativeEvent.key !== "Enter") return;
const action = resolveMessageInputEnterAction({
submitOnEnter: ctx.submitOnEnter,
sendShortcut: ctx.sendShortcut,
shiftKey: shiftKey ?? false,
metaKey: metaKey ?? false,
ctrlKey: ctrlKey ?? false,
isAgentRunning: ctx.isAgentRunning,
onQueue: ctx.onQueue,
});
if (action === null) return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (action === "alternate-send") {
if (metaKey || ctrlKey) {
ctx.handleAlternateSendAction();
return;
}
@@ -1132,7 +1112,6 @@ interface ResolvedMessageInputProps {
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
@@ -1180,7 +1159,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
sendShortcut: props.sendShortcut,
onQueue: props.onQueue,
onSubmitLoadingPress: props.onSubmitLoadingPress,
onKeyPressCallback: props.onKeyPress,
@@ -1258,7 +1236,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
@@ -1778,9 +1755,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1792,9 +1766,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -2009,7 +1980,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={resolveSendKeys(sendShortcut)}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
/>
),
@@ -2030,7 +2001,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel,
submitButtonTestID,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
);
@@ -2,12 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import {
applyDictationTranscript,
computeCanStartDictation,
type MessageInputEnterContext,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveMessageInputEnterAction,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
@@ -322,212 +320,6 @@ describe("composer send behavior", () => {
});
});
describe("resolveMessageInputEnterAction", () => {
function enterContext(
overrides: Partial<MessageInputEnterContext> = {},
): MessageInputEnterContext {
return {
submitOnEnter: true,
sendShortcut: "enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
isAgentRunning: false,
onQueue: () => undefined,
...overrides,
};
}
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
).toBeNull();
});
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
),
).toBeNull();
});
it("sends on plain Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
});
it("returns null for Shift+Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
});
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
expect(
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
).toBe("alternate-send");
expect(
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
).toBe("alternate-send");
});
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
});
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
).toBe("default-send");
});
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
),
).toBe("default-send");
});
describe("composed with the send actions", () => {
function actions() {
const calls: string[] = [];
return {
calls,
handleSendMessage: () => calls.push("send"),
handleQueueMessage: () => calls.push("queue"),
onQueue: () => undefined,
};
}
function resolveCommandEnterToSendModifierEnter() {
return resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
);
}
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["queue"]);
});
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
const sendAction = actions();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
),
).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: false,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
});
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
({ defaultSendBehavior }) => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior,
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
},
);
});
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
-24
View File
@@ -1,4 +1,3 @@
import type { SendShortcut } from "@/hooks/use-settings";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
@@ -152,29 +151,6 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
runDefaultSendAction(ctx);
}
export type MessageInputEnterAction = "alternate-send" | "default-send";
export interface MessageInputEnterContext {
submitOnEnter: boolean;
sendShortcut: SendShortcut;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
}
export function resolveMessageInputEnterAction(
ctx: MessageInputEnterContext,
): MessageInputEnterAction | null {
if (!ctx.submitOnEnter) return null;
if (ctx.shiftKey) return null;
const hasModifier = ctx.metaKey || ctx.ctrlKey;
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
return "default-send";
}
export function runMessageInputKeyboardAction(
action: MessageInputKeyboardActionKind,
actions: MessageInputKeyboardActions,
@@ -2,7 +2,9 @@ import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import type { UserMessageItem } from "@/types/stream";
import { createMessageSubmissionWriter } from "./writer";
import { useDraftStore } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { createMessageSubmissionWriter, transferComposerFollowUps } from "./writer";
const CANONICAL_ONLY_SERVER_ID = "canonical-only";
@@ -47,3 +49,59 @@ test("tracks submissions when canonical prompts are supported", () => {
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
});
const TRANSFER_SERVER_ID = "transfer";
function queued(id: string) {
return { id, text: `follow-up ${id}`, attachments: [] };
}
function queueIds(agentId: string): string[] {
return (
useSessionStore.getState().sessions[TRANSFER_SERVER_ID]?.queuedMessages.get(agentId) ?? []
).map((item) => item.id);
}
function draftText(agentId: string): string | undefined {
return useDraftStore
.getState()
.getDraftInput(buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }))?.text;
}
function typeDraft(agentId: string, text: string): void {
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }),
draft: { text, attachments: [] },
});
}
test("follow-ups move with their IDs and order behind the destination's, and only once", () => {
initializeSession(TRANSFER_SERVER_ID, {});
useSessionStore.getState().setQueuedMessages(
TRANSFER_SERVER_ID,
new Map([
["optimistic", [queued("a"), queued("b")]],
["recovery", [queued("earlier")]],
]),
);
typeDraft("optimistic", "typed but unsent");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "recovery", "recovery");
expect(queueIds("recovery")).toEqual(["earlier", "a", "b"]);
expect(queueIds("optimistic")).toEqual([]);
expect(draftText("recovery")).toBe("typed but unsent");
expect(draftText("optimistic")).toBeUndefined();
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
test("unsent text never replaces a destination draft and stays at its source", () => {
initializeSession(TRANSFER_SERVER_ID, {});
typeDraft("source", "typed on the source");
typeDraft("destination", "already typed here");
transferComposerFollowUps(TRANSFER_SERVER_ID, "source", "destination");
expect(draftText("destination")).toBe("already typed here");
expect(draftText("source")).toBe("typed on the source");
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
+49 -1
View File
@@ -1,5 +1,7 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import type { MessageSubmissionWriter, QueueWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
@@ -63,6 +65,14 @@ export function createMessageSubmissionWriter(serverId: string): MessageSubmissi
};
}
export function createAgentMessageQueueWriter(serverId: string): QueueWriter {
return {
read: (agentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
};
}
export function handoffCreatedAgentMessageSubmission(
serverId: string,
agentId: string,
@@ -70,3 +80,41 @@ export function handoffCreatedAgentMessageSubmission(
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
}
function isEmptyDraft(draft: DraftInput | undefined): boolean {
return !draft || (!draft.text.trim() && draft.attachments.length === 0);
}
/**
* Moves the queued follow-ups and the unsent agent draft of one composer identity to the identity
* that now owns them, such as a recovery draft or the agent it creates. Queued messages keep their
* IDs and order, after any already queued there; an idle destination agent drains them. The unsent
* draft moves only into an empty slot, and otherwise stays where it is. Repeating is harmless.
*/
export function transferComposerFollowUps(serverId: string, from: string, to: string): void {
if (from === to) return;
const session = useSessionStore.getState().sessions[serverId];
if (session?.queuedMessages.get(from)?.length) {
useSessionStore.getState().setQueuedMessages(serverId, (queues) => {
const moving = queues.get(from);
if (!moving?.length) return queues;
const next = new Map(queues);
next.set(to, [...(queues.get(to) ?? []), ...moving]);
next.delete(from);
return next;
});
const agent = session.agents.get(to) ?? session.agentDetails.get(to);
if (agent?.turn.phase === "idle") {
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
getHostRuntimeStore().drainQueuedAgentMessage(serverId, to),
);
}
}
const drafts = useDraftStore.getState();
const source = buildDraftStoreKey({ serverId, agentId: from });
const destination = buildDraftStoreKey({ serverId, agentId: to });
const draft = drafts.getDraftInput(source);
if (!draft || isEmptyDraft(draft) || !isEmptyDraft(drafts.getDraftInput(destination))) return;
drafts.saveDraftInput({ draftKey: destination, draft });
drafts.clearDraftInput({ draftKey: source });
}
@@ -1,65 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
/**
* The diff shapes the layout benchmarks measure, shared by the node bench
* (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`)
* so both report against the same text.
*/
export interface BenchmarkCorpusFile {
path: string;
lines: string[];
}
function repeatToLength(unit: string, length: number): string {
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
}
/** A real source file: short lines, a few with ligature punctuation. */
export function realCodeCorpusFile(): BenchmarkCorpusFile {
const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8");
return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") };
}
/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */
export function changelogCorpusFile(): BenchmarkCorpusFile {
const sentence =
"- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. ";
return {
path: "CHANGELOG.md",
lines: Array.from({ length: 42 }, (_, index) =>
repeatToLength(sentence, 200 + ((index * 97) % 398)),
),
};
}
/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */
export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile {
return {
path: "src/generated.ts",
lines: Array.from({ length: lineCount }, (_, index) => {
const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`;
return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base;
}),
};
}
/** Serialized data: no ligatures, every line wraps several times. */
export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile {
return {
path: "fixtures/data.json",
lines: Array.from({ length: lineCount }, (_, index) =>
repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300),
),
};
}
/** A pull-request-sized working diff: one of each shape above. */
export function pullRequestCorpus(): BenchmarkCorpusFile[] {
return [
realCodeCorpusFile(),
changelogCorpusFile(),
generatedCodeCorpusFile(1200),
jsonCorpusFile(300),
];
}
@@ -1,317 +0,0 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { bench, describe } from "vitest";
import {
changelogCorpusFile,
generatedCodeCorpusFile,
jsonCorpusFile,
pullRequestCorpus,
realCodeCorpusFile,
type BenchmarkCorpusFile,
} from "./benchmark-corpus";
import { buildDiffDocumentModel } from "./model";
import {
createAdditiveAdvances,
createChunkedAdvanceMeasurer,
createChunkedWidthMeasurer,
createMeasuredAdvances,
requiresShaping,
} from "./text-measurement";
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
// The pure layout work the diff surface does when a diff opens or line wrap is
// toggled, without a browser or Skia. Counts are the backend-independent cost
// owners (how many strings a shaper has to see), timings are the JS overhead
// around them:
// npx vitest bench --project unit src/git/diff-document/model.bench.ts
const DESKTOP_VIEWPORT = { width: 1400, height: 900 };
const PHONE_VIEWPORT = { width: 390, height: 800 };
const CELL_WIDTH = 7.8;
interface MeasurementStats {
/** Whole strings handed to the backend. */
calls: number;
/** Characters inside those strings; canvas and Skia cost scales with this. */
characters: number;
}
interface Backend {
measurer: TextMeasurer;
stats: MeasurementStats;
}
function monospaceWidth(text: string): number {
let width = 0;
for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1;
return width * CELL_WIDTH;
}
function countingMeasure(stats: MeasurementStats): (text: string) => number {
return (text) => {
stats.calls += 1;
stats.characters += text.length;
return monospaceWidth(text);
};
}
/** Previous web backend, retained to isolate the cost of width-only probes. */
function webBackendWithoutWidthProbe(): Backend {
const stats: MeasurementStats = { calls: 0, characters: 0 };
const measure = countingMeasure(stats);
return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } };
}
/**
* `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter.
* Width-only probes shape one string per chunk, like the native measurer; the
* widths are the same sums, in the same order, as the last cumulative advance.
*/
function webBackend(): Backend {
const stats: MeasurementStats = { calls: 0, characters: 0 };
const measure = countingMeasure(stats);
const additive = createAdditiveAdvances(measure);
return {
stats,
measurer: {
measure,
measureAdvances: createMeasuredAdvances(measure),
measureWidth: createChunkedWidthMeasurer((graphemes) => {
const text = graphemes.join("");
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
}),
},
};
}
/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */
function nativeBackend(): Backend {
const stats: MeasurementStats = { calls: 0, characters: 0 };
const widths = new Map<string, number>();
const additive = (graphemes: readonly string[]) => {
let advance = 0;
return graphemes.map((grapheme) => {
let width = widths.get(grapheme);
if (width === undefined) {
stats.calls += 1;
stats.characters += grapheme.length;
width = monospaceWidth(grapheme);
widths.set(grapheme, width);
}
return (advance += width);
});
};
const shape = countingMeasure(stats);
return {
stats,
measurer: {
measure(text) {
return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0);
},
measureWidth: createChunkedWidthMeasurer((graphemes) => {
const text = graphemes.join("");
return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0);
}),
measureAdvances: createChunkedAdvanceMeasurer({
requiresShaping,
measureAdditive: additive,
measureShaped(graphemes) {
shape(graphemes.join(""));
let advance = 0;
return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme)));
},
}),
},
};
}
function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile {
return {
path: file.path,
isNew: true,
isDeleted: false,
additions: file.lines.length,
deletions: 0,
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: file.lines.length,
lines: file.lines.map((content) => ({
type: "add" as const,
content,
tokens: [{ text: content, style: null }],
})),
},
],
};
}
interface Scenario {
name: string;
files: ParsedDiffFile[];
}
const SCENARIOS: Scenario[] = [
{ name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] },
{ name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] },
{ name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] },
{ name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] },
{ name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) },
];
function input(
scenario: Scenario,
options: {
wrapLines: boolean;
viewport: { width: number; height: number };
measureText: TextMeasurer;
},
): BuildDiffDocumentModelInput {
return {
files: scenario.files,
collapsedFilePaths: new Set(),
layout: "unified",
wrapLines: options.wrapLines,
viewportWidth: options.viewport.width,
typography: { family: "monospace", size: 13, lineHeight: 20 },
measureText: options.measureText,
palette: {
surface: "#000",
headerSurface: "#111",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
addition: "green",
deletion: "red",
additionBackground: "#010",
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
// What the surfaces pass at scrollTop 0: five viewports tall.
materializationWindow: { top: 0, height: options.viewport.height * 5 },
};
}
interface Variant {
name: string;
wrapLines: boolean;
viewport: { width: number; height: number };
backend: () => Backend;
}
const VARIANTS: Variant[] = [
{
name: "unwrapped desktop | web",
wrapLines: false,
viewport: DESKTOP_VIEWPORT,
backend: webBackend,
},
{
name: "wrapped desktop | web",
wrapLines: true,
viewport: DESKTOP_VIEWPORT,
backend: webBackend,
},
{
name: "wrapped desktop | web without width probe",
wrapLines: true,
viewport: DESKTOP_VIEWPORT,
backend: webBackendWithoutWidthProbe,
},
{
name: "wrapped phone | web",
wrapLines: true,
viewport: PHONE_VIEWPORT,
backend: webBackend,
},
{
name: "unwrapped desktop | native",
wrapLines: false,
viewport: DESKTOP_VIEWPORT,
backend: nativeBackend,
},
{
name: "wrapped desktop | native",
wrapLines: true,
viewport: DESKTOP_VIEWPORT,
backend: nativeBackend,
},
{
name: "wrapped phone | native",
wrapLines: true,
viewport: PHONE_VIEWPORT,
backend: nativeBackend,
},
];
function countModel(scenario: Scenario, variant: Variant) {
const { measurer, stats } = variant.backend();
const started = performance.now();
const model = buildDiffDocumentModel(
input(scenario, {
wrapLines: variant.wrapLines,
viewport: variant.viewport,
measureText: measurer,
}),
);
const elapsedMs = performance.now() - started;
let materializedCells = 0;
let fragments = 0;
let graphemes = 0;
for (const row of model.rows) {
if (row.kind !== "line") continue;
for (const cell of row.cells) {
if (!cell || cell.fragments.length === 0) continue;
materializedCells += 1;
fragments += cell.fragments.length;
for (const fragment of cell.fragments) graphemes += fragment.graphemes.length;
}
}
const sourceLines = scenario.files.reduce(
(count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0),
0,
);
return {
sourceLines,
materializedCells,
fragments,
graphemeObjects: graphemes,
measureCalls: stats.calls,
measuredCharacters: stats.characters,
coldMs: Number(elapsedMs.toFixed(1)),
};
}
const report: Record<string, ReturnType<typeof countModel>> = {};
for (const scenario of SCENARIOS) {
for (const variant of VARIANTS) {
report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant);
}
}
console.table(report);
for (const scenario of SCENARIOS) {
describe(scenario.name, () => {
for (const variant of VARIANTS) {
if (variant.backend === nativeBackend) continue;
bench(variant.name, () => {
buildDiffDocumentModel(
input(scenario, {
wrapLines: variant.wrapLines,
viewport: variant.viewport,
measureText: variant.backend().measurer,
}),
);
});
}
});
}
@@ -10,7 +10,7 @@ import {
resolveRelayoutScrollTop,
shouldApplyRelayoutScroll,
} from "./model";
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
@@ -639,119 +639,17 @@ describe("diff document model", () => {
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
});
it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
const changed = fileWithChangedLine("x".repeat(100));
const files = [changed, file("later.ts")];
// Deliberately inaccurate M width makes the estimate differ from exact layout.
const measureText = {
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
};
const options = input({ files, measureText });
const first = buildDiffDocumentModel({
...options,
materializationWindow: { top: 30, height: 1 },
});
const eager = buildDiffDocumentModel(options);
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
expect(first.height).not.toBe(eager.height);
expect(exact.rows).toEqual(eager.rows);
expect(exact.height).toBe(eager.height);
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
const scrollTop = first.files[1]!.bodyTop + 3;
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
captureScrollAnchor(first, scrollTop),
);
});
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
const source = file();
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
type: "add" as const,
content: "x".repeat(200),
}));
const options = input({ files: [source], viewportWidth: 500 });
const before = buildDiffDocumentModel(options);
const scrollTop = before.rows[100]!.top + 36;
const anchor = captureScrollAnchor(before, scrollTop)!;
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
const resizedOptions = { ...options, viewportWidth: 180 };
const estimated = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: {
top: Math.floor(scrollTop / 800) * 800 - 800,
height: 2000,
},
});
const estimatedRow = estimated.rows[100]!;
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
const recaptured = captureScrollAnchor(estimated, restored)!;
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
const materialized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: restored - 800, height: 2000 },
reuseFrom: [estimated],
});
const eager = buildDiffDocumentModel(resizedOptions);
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
expect(expected).toBe(28_938);
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
});
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
const source = file();
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
type: "add" as const,
content: "x".repeat(200),
}));
const options = input({ files: [source], viewportWidth: 500 });
const before = buildDiffDocumentModel(options);
const scrollTop = before.rows[100]!.top + 38;
const anchor = captureScrollAnchor(before, scrollTop)!;
const resizedOptions = {
...options,
viewportWidth: 180,
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
};
const resized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: scrollTop - 800, height: 2000 },
materializationAnchor: anchor,
});
const row = resized.rows[100]!;
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
const materialized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: restored - 800, height: 2000 },
materializationAnchor: captureScrollAnchor(resized, restored),
reuseFrom: [resized],
});
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
});
it("materializes wrapped text only inside the window", () => {
it("keeps wrapped geometry fully measured", () => {
const model = buildDiffDocumentModel(
input({
files: [file("a.ts"), file("b.ts")],
wrapLines: true,
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
}),
);
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(model, "b.ts")).toBe(0);
expect(
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
).toHaveLength(1);
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
});
});
@@ -900,7 +798,3 @@ function reviewActionsWithEditor(
onDeleteComment() {},
};
}
function hasFragments(cell: DiffCell | null): boolean {
return cell !== null && cell.fragments.length > 0;
}
+21 -113
View File
@@ -154,38 +154,27 @@ function appendReusableFileRows(candidate: {
}): FileRowsResult {
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
const retainsRowGeometry =
!candidate.input.wrapLines &&
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
candidate.file.top === candidate.fileTop &&
candidate.file.fileIndex === candidate.fileIndex &&
candidate.file.rowStart === candidate.rows.length;
let top = candidate.bodyTop;
for (const reusedRow of reusedRows) {
let nextRow =
retainsRowGeometry && reusedRow.top === top
? reusedRow
: repositionReusedRow(reusedRow, candidate, top);
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
if (
nextRow.kind === "line" &&
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
) {
const cells = materializeCells(
nextRow.cells,
candidate.file.file,
candidate.file.gutterWidth,
candidate.input,
);
nextRow = {
...nextRow,
cells,
height:
Math.max(
candidate.input.typography.lineHeight,
...cells.map(
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
),
) + nextRow.reviewHeight,
cells: materializeCells(
nextRow.cells,
candidate.file.file,
candidate.file.gutterWidth,
candidate.input,
),
};
}
candidate.rows.push(nextRow);
@@ -251,7 +240,6 @@ function appendNewFileRows(candidate: {
const lines = geometryLines(
lineSources(candidate.file, candidate.input.layout, false),
candidate.input,
candidate.gutterWidth,
);
const fileBottom =
candidate.bodyTop +
@@ -264,8 +252,8 @@ function appendNewFileRows(candidate: {
let maximumHorizontalOverflow = 0;
for (const line of lines) {
const shouldMaterialize =
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
candidate.input.wrapLines ||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
const cells = shouldMaterialize
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
: line.cells;
@@ -287,7 +275,7 @@ function appendNewFileRows(candidate: {
candidate.input.typography.lineHeight,
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
);
const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
const height = textHeight + line.reviewHeight;
candidate.rows.push({
kind: "line",
index: candidate.rows.length,
@@ -424,14 +412,13 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean):
};
}
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
function geometryCell(source: CellSource): DiffCell {
return {
type: source.type,
content: source.content,
lineNumber: source.lineNumber,
tokens: [],
fragments: [],
estimatedWrapColumns,
reviewTarget: source.reviewTarget,
sourceIdentity: source.sourceIdentity,
};
@@ -440,87 +427,20 @@ function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCel
function geometryLines(
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
input: BuildDiffDocumentModelInput,
gutterWidth: number,
): GeometryLine[] {
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
return sources.map((sourceCells) => {
const columns = input.wrapLines
? Math.max(
1,
Math.floor(
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
characterWidth,
),
)
: Infinity;
const cells = sourceCells.map((source) =>
source ? geometryCell(source, columns) : null,
source ? geometryCell(source) : null,
) as DiffLineRow["cells"];
const reviewHeight = reviewHeightForCells(cells, input);
return {
cells,
reviewHeight,
// Unseen wrapped rows use a monospace estimate; materialization replaces
// it with the exact fragment count without changing source identity.
height:
input.typography.lineHeight *
Math.max(
1,
...cells.map((cell) => {
return cell && input.wrapLines
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
: 1;
}),
) +
reviewHeight,
height: input.typography.lineHeight + reviewHeight,
};
});
}
// Use the same tab expansion and column count for estimated heights and anchors.
function estimatedDisplayOffset(text: string): number {
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
}
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
return Math.floor(
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
(cell.estimatedWrapColumns ?? Infinity),
);
}
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
const columns = cell.estimatedWrapColumns ?? Infinity;
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
const index = Math.min(visualIndex, lastIndex);
const displayOffset = index === 0 ? 0 : index * columns;
let start = Math.min(displayOffset, cell.content.length);
if (cell.content.includes("\t")) {
let offset = 0;
start = 0;
while (start < cell.content.length && offset < displayOffset) {
offset += cell.content[start] === "\t" ? 4 : 1;
start += 1;
}
}
return { start, top: index * lineHeight };
}
// A resize can move the anchor outside the old window. Measure that row in the
// same pass: estimated columns cannot identify exact shaped-text fragments.
function containsMaterializationAnchor(
input: BuildDiffDocumentModelInput,
path: string,
cells: DiffLineRow["cells"],
): boolean {
const anchor = input.materializationAnchor;
return (
anchor?.kind === "text" &&
anchor.path === path &&
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
);
}
function intersectsMaterializationWindow(
input: BuildDiffDocumentModelInput,
fileTop: number,
@@ -875,27 +795,17 @@ export function captureScrollAnchor(
}
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
let cellIndex = row.cells.findIndex(
(cell) =>
cell &&
(cell.fragments.length > 0
? Boolean(cell.fragments[visualIndex])
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
visualIndex * model.lineHeight),
);
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
const cell = row.cells[cellIndex];
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
const fragment =
cell.fragments.length > 0
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
: estimatedFragment(cell, visualIndex, model.lineHeight);
const anchorY = row.top + fragment.top;
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
const anchorY = row.top + (fragment?.top ?? 0);
return {
kind: "text",
path: file.path,
sourceIdentity: cell.sourceIdentity,
cellOffset: fragment.start,
cellOffset: fragment?.start ?? 0,
viewportDelta: anchorY - scrollTop,
};
}
@@ -921,10 +831,8 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
const fragment =
cell.fragments.find(
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
) ??
cell.fragments.at(-1) ??
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
) ?? cell.fragments.at(-1);
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
}
export function resolveRelayoutScrollTop(
@@ -38,7 +38,6 @@ import {
} from "./horizontal-offsets";
import {
buildDiffDocumentModel,
captureScrollAnchor,
resolveRelayoutScrollTop,
retainReusableModels,
shouldApplyRelayoutScroll,
@@ -139,9 +138,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
materializationAnchor: previousModelRef.current
? captureScrollAnchor(previousModelRef.current, scrollTop.get())
: null,
reuseFrom,
});
reusableModelRef.current = {
@@ -152,7 +148,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, [
measurement,
fileWindowTop,
scrollTop,
props.collapsedFilePaths,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
@@ -28,20 +28,10 @@ import {
} from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import {
buildDiffDocumentModel,
captureScrollAnchor,
FILE_HEADER_HEIGHT,
resolveRelayoutScrollTop,
} from "./model";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import {
createMeasuredAdvances,
createAdditiveAdvances,
createChunkedWidthMeasurer,
requiresShaping,
} from "./text-measurement";
import { createMeasuredAdvances } from "./text-measurement";
import { retainDiffViewport } from "./viewport";
import type {
DiffHit,
@@ -193,9 +183,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
materializationAnchor: previousModelRef.current
? captureScrollAnchor(previousModelRef.current, scrollTopRef.current)
: null,
});
return next;
}, [
@@ -1179,13 +1166,5 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
};
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
const additive = createAdditiveAdvances(measure);
return {
measure,
measureAdvances: createMeasuredAdvances(measure),
measureWidth: createChunkedWidthMeasurer((graphemes) => {
const text = graphemes.join("");
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
}),
};
return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) };
}
@@ -112,8 +112,6 @@ export interface DiffCell {
lineNumber: number | null;
tokens: DiffTokenRun[];
fragments: DiffFragment[];
/** Monospace columns per visual line until this cell is measured. */
estimatedWrapColumns?: number;
reviewTarget: ReviewableDiffTarget | null;
sourceIdentity: DiffSourceIdentity;
}
@@ -187,8 +185,6 @@ export interface BuildDiffDocumentModelInput {
reviewActions?: InlineReviewActions;
labels: { binary: string; tooLarge: string };
materializationWindow?: { top: number; height: number };
/** Also measure the anchored row so relayout can restore its exact fragment. */
materializationAnchor?: DiffScrollAnchor | null;
/** A geometry-compatible model whose unchanged file measurements may be reused. */
reuseFrom?: readonly DiffDocumentModel[];
}
@@ -95,37 +95,6 @@ describe("diff document workspace cache", () => {
expect(stats.calls).toBe(0);
});
it("materializes a new anchor when the viewport window is unchanged", () => {
const cache = createDiffDocumentWorkspaceCache();
const source = diffFile();
source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({
type: "add" as const,
content: "x".repeat(200),
}));
const { measureText } = countingMeasurer();
const input = modelInput([source], measureText, {
wrapLines: true,
materializationWindow: { top: 30, height: 54 },
});
const first = cache.buildModel(input);
const anchored = cache.buildModel({
...input,
materializationAnchor: {
kind: "text",
path: source.path,
sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" },
cellOffset: 90,
viewportDelta: -2,
},
});
const firstRow = first.rows[50]!;
const anchoredRow = anchored.rows[50]!;
expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0);
expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan(
0,
);
});
it("retains text cells for unchanged files when another file changes", () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
@@ -128,9 +128,7 @@ function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): s
const materializationWindow = input.materializationWindow
? [input.materializationWindow.top, input.materializationWindow.height]
: null;
const anchor = input.materializationAnchor;
const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null;
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]);
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
}
function typographyKey(typography: DiffTypography): string {
@@ -1,3 +1,4 @@
import type { CreationAttempt } from "@/workspace-creation/model";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
import equal from "fast-deep-equal";
import type { PrHint } from "@/git/pr-hint";
@@ -146,6 +147,7 @@ export function createSidebarWorkspaceEntry(input: {
workspace: WorkspaceDescriptor;
projectViewKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
@@ -203,6 +205,7 @@ export function deriveProjectStatusBucket(input: {
workspaces: readonly SidebarWorkspacePlacement[];
sessions: Record<string, ProjectStatusSession | undefined>;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
}): SidebarStateBucket {
const workspaceIdsByServer = new Map<string, string[]>();
for (const placement of input.workspaces) {
@@ -230,6 +233,7 @@ export function deriveProjectStatusBucket(input: {
serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity,
}).status,
);
@@ -348,6 +352,7 @@ export function buildSidebarWorkspaceEntries(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
}): ReadonlyMap<string, SidebarWorkspaceEntry> {
if (input.placements.length === 0 || input.sessions.length === 0) {
@@ -374,6 +379,7 @@ export function buildSidebarWorkspaceEntries(input: {
workspace,
projectViewKey: placement.projectViewKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity,
});
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
@@ -78,7 +78,6 @@ describe("ensureAgentIsInitialized", () => {
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -106,7 +105,6 @@ describe("ensureAgentIsInitialized", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -150,7 +148,6 @@ describe("ensureAgentIsInitialized", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -195,7 +192,6 @@ describe("ensureAgentIsInitialized", () => {
cursor: { epoch: "epoch-1", seq: 12 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -287,7 +283,6 @@ describe("refreshAgent", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -152,7 +152,6 @@ describe("loadOlderAgentHistory", () => {
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -49,7 +49,6 @@ import {
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type SendShortcut,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
@@ -94,7 +93,6 @@ export type {
KeyValueStorage,
ReleaseChannel,
SendBehavior,
SendShortcut,
ServiceUrlBehavior,
Settings,
SettingsDeps,
@@ -227,39 +227,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("uses Enter to send by default", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
});
it("loads the modifier Enter send preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("mod-enter");
});
it("falls back to Enter when the stored send shortcut is invalid", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
@@ -33,8 +33,6 @@ import { migrateAppSettings } from "./migrations";
export { APP_SETTINGS_KEY } from "./keys";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendShortcut = "enter" | "mod-enter";
export type SendBehavior = ActiveTurnBehavior | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
@@ -106,7 +104,6 @@ export interface AppSettings {
chatDisplay: ChatDisplay;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
sendShortcut: SendShortcut;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
@@ -163,7 +160,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
sendShortcut: "enter",
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
@@ -268,7 +264,6 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { creatingSidebarEntry } from "@/workspace-creation/presentation";
import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -19,6 +22,7 @@ export function useSidebarWorkspaceEntries(
placements: readonly SidebarWorkspacePlacement[],
enabled = true,
): ReadonlyMap<string, SidebarWorkspaceEntry> {
const { t } = useTranslation();
const serverIds = useMemo(
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements],
@@ -32,6 +36,7 @@ export function useSidebarWorkspaceEntries(
const pendingCreateAttempts = useCreateFlowStore((state) =>
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const creations = useWorkspaceCreations();
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
// Collection ownership is intentional: retained sidebars have one cheap
@@ -45,13 +50,26 @@ export function useSidebarWorkspaceEntries(
previousEntriesRef.current = EMPTY_ENTRIES;
return EMPTY_ENTRIES;
}
const entries = buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
previousEntries: previousEntriesRef.current,
});
const entries = new Map(
buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
workspaceCreations: creations,
previousEntries: previousEntriesRef.current,
}),
);
for (const placement of placements) {
if (entries.has(placement.workspaceKey)) continue;
const creating = creations.some(
(creation) =>
creation.serverId === placement.serverId &&
creation.workspaceId === placement.workspaceId &&
!creation.workspaceObserved,
);
if (creating) entries.set(placement.workspaceKey, creatingSidebarEntry(placement, t));
}
previousEntriesRef.current = entries;
return entries;
}, [enabled, pendingCreateAttempts, placements, sessions]);
}, [enabled, pendingCreateAttempts, placements, sessions, creations, t]);
}
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -59,6 +62,7 @@ export function useSidebarProjectStatusBucket(input: {
enabled: boolean;
}): SidebarStateBucket | null {
const { workspaces, enabled } = input;
const workspaceCreations = useWorkspaceCreations();
const pendingCreateAttempts = useStoreWithEqualityFn(
useCreateFlowStore,
(state) => state.pendingByDraftId,
@@ -72,9 +76,10 @@ export function useSidebarProjectStatusBucket(input: {
workspaces,
sessions: state.sessions,
pendingCreateAttempts,
workspaceCreations,
});
},
[enabled, pendingCreateAttempts, workspaces],
[enabled, pendingCreateAttempts, workspaces, workspaceCreations],
);
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
@@ -150,10 +155,12 @@ export function useSidebarWorkspacesList(options?: {
: undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const { t } = useTranslation();
const creations = useWorkspaceCreations();
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
const sidebarModel = useMemo(
() => selectPlacements(hostProjects),
[hostProjects, selectPlacements],
() => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t),
[hostProjects, selectPlacements, creations, t],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
+13 -18
View File
@@ -1339,6 +1339,15 @@ export const ar: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
creatingLabel: "جارٍ إنشاء مساحة العمل",
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
chooseProjectHost: "اختر مشروعًا ومضيفًا",
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
},
title: "مساحة عمل جديدة",
create: "يخلق",
isolation: {
@@ -2018,13 +2027,12 @@ export const ar: TranslationResources = {
output: "الإخراج",
},
turnSummary: {
details: "عرض تفاصيل الجولة",
sentBy: "أرسلها {{name}}",
showMore: "عرض المزيد",
showLess: "عرض أقل",
messages: {
one: "كتب {{count}} رسالة",
other: "كتب {{count}} رسائل",
},
},
toolCallGroup: {
thought: "فكّر",
editedFiles: {
one: "حرّر {{count}} ملفًا",
other: "حرّر {{count}} ملفات",
@@ -2370,25 +2378,12 @@ export const ar: TranslationResources = {
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
},
commandEnterDescriptions: {
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
queue:
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
},
options: {
interrupt: "مقاطعة",
steer: "توجيه",
queue: "طابور",
},
},
sendShortcut: {
label: "اختصار الإرسال",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "بسأل",
+13 -19
View File
@@ -1347,6 +1347,15 @@ export const en = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creating workspace…",
creatingLabel: "Creating workspace",
updateHost: "Update this host to create a workspace with an agent.",
chooseProjectHost: "Choose a project and host",
projectUnavailable: "Project is not available on the selected host",
workspacePending: "Workspace is still being created.",
agentPending: "Agent is still being created.",
},
title: "New workspace",
create: "Create",
isolation: {
@@ -2043,13 +2052,12 @@ export const en = {
output: "Output",
},
turnSummary: {
details: "View turn details",
sentBy: "Sent by {{name}}",
showMore: "Show more",
showLess: "Show less",
messages: {
one: "wrote {{count}} message",
other: "wrote {{count}} messages",
},
},
toolCallGroup: {
thought: "Thought",
editedFiles: {
one: "edited {{count}} file",
other: "edited {{count}} files",
@@ -2458,26 +2466,12 @@ export const en = {
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
},
commandEnterDescriptions: {
interrupt:
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
steer:
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
},
options: {
interrupt: "Interrupt",
steer: "Steer",
queue: "Queue",
},
},
sendShortcut: {
label: "Send shortcut",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Ask",
+13 -20
View File
@@ -1375,6 +1375,15 @@ export const es: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creando espacio de trabajo…",
creatingLabel: "Creando espacio de trabajo",
updateHost: "Actualiza este host para crear un espacio de trabajo con un agente.",
chooseProjectHost: "Elige un proyecto y un host",
projectUnavailable: "El proyecto no está disponible en el host seleccionado",
workspacePending: "El espacio de trabajo aún se está creando.",
agentPending: "El agente aún se está creando.",
},
title: "Nuevo espacio de trabajo",
create: "Crear",
isolation: {
@@ -2066,13 +2075,12 @@ export const es: TranslationResources = {
output: "Producción",
},
turnSummary: {
details: "Ver detalles del turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar más",
showLess: "Mostrar menos",
messages: {
one: "escribió {{count}} mensaje",
other: "escribió {{count}} mensajes",
},
},
toolCallGroup: {
thought: "Razonó",
editedFiles: {
one: "editó {{count}} archivo",
other: "editó {{count}} archivos",
@@ -2385,27 +2393,12 @@ export const es: TranslationResources = {
queue:
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
},
commandEnterDescriptions: {
interrupt:
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
steer:
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
queue:
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
},
options: {
interrupt: "Interrumpir",
steer: "Dirigir",
queue: "Cola",
},
},
sendShortcut: {
label: "Atajo de envío",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Preguntar",
+13 -20
View File
@@ -1369,6 +1369,15 @@ export const fr: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Création de l’espace de travail…",
creatingLabel: "Création de l’espace de travail",
updateHost: "Mettez cet hôte à jour pour créer un espace de travail avec un agent.",
chooseProjectHost: "Choisissez un projet et un hôte",
projectUnavailable: "Le projet n’est pas disponible sur l’hôte sélectionné",
workspacePending: "L’espace de travail est en cours de création.",
agentPending: "L’agent est en cours de création.",
},
title: "Nouvel espace de travail",
create: "Créer",
isolation: {
@@ -2065,13 +2074,12 @@ export const fr: TranslationResources = {
output: "Sortie",
},
turnSummary: {
details: "Voir les détails du tour",
sentBy: "Envoyé par {{name}}",
showMore: "Afficher plus",
showLess: "Afficher moins",
messages: {
one: "{{count}} message écrit",
other: "{{count}} messages écrits",
},
},
toolCallGroup: {
thought: "Réfléchi",
editedFiles: {
one: "{{count}} fichier modifié",
other: "{{count}} fichiers modifiés",
@@ -2384,27 +2392,12 @@ export const fr: TranslationResources = {
queue:
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
},
commandEnterDescriptions: {
interrupt:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
steer:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
queue:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
},
options: {
interrupt: "Interrompre",
steer: "Réorienter",
queue: "File d’attente",
},
},
sendShortcut: {
label: "Raccourci d’envoi",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Demander",
+13 -18
View File
@@ -1353,6 +1353,15 @@ export const ja: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "ワークスペースを作成中…",
creatingLabel: "ワークスペースを作成中",
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
chooseProjectHost: "プロジェクトとホストを選択してください",
projectUnavailable: "選択したホストでプロジェクトを利用できません",
workspacePending: "ワークスペースはまだ作成中です。",
agentPending: "エージェントはまだ作成中です。",
},
title: "新しいワークスペース",
create: "作成",
isolation: {
@@ -2038,13 +2047,12 @@ export const ja: TranslationResources = {
output: "出力",
},
turnSummary: {
details: "ターンの詳細を表示",
sentBy: "{{name}} から送信",
showMore: "もっと見る",
showLess: "折りたたむ",
messages: {
one: "{{count}}件のメッセージを送信",
other: "{{count}}件のメッセージを送信",
},
},
toolCallGroup: {
thought: "思考",
editedFiles: {
one: "{{count}}個のファイルを編集",
other: "{{count}}個のファイルを編集",
@@ -2354,25 +2362,12 @@ export const ja: TranslationResources = {
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
},
commandEnterDescriptions: {
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
steer:
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
},
options: {
interrupt: "中断",
steer: "指示",
queue: "キュー",
},
},
sendShortcut: {
label: "送信ショートカット",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "確認する",
+13 -20
View File
@@ -1346,6 +1346,15 @@ export const ko: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "워크스페이스 생성 중…",
creatingLabel: "워크스페이스 생성 중",
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
workspacePending: "워크스페이스가 아직 생성 중입니다.",
agentPending: "에이전트가 아직 생성 중입니다.",
},
title: "새 워크스페이스",
create: "생성",
isolation: {
@@ -2028,13 +2037,12 @@ export const ko: TranslationResources = {
output: "출력",
},
turnSummary: {
details: "턴 세부 정보 보기",
sentBy: "{{name}}이(가) 보냄",
showMore: "더 보기",
showLess: "접기",
messages: {
one: "{{count}} 메시지를 작성했습니다.",
other: "{{count}} 메시지를 작성했습니다.",
},
},
toolCallGroup: {
thought: "생각함",
editedFiles: {
one: "{{count}} 파일을 편집했습니다.",
other: "{{count}} 파일을 편집했습니다.",
@@ -2345,27 +2353,12 @@ export const ko: TranslationResources = {
queue:
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
},
commandEnterDescriptions: {
interrupt:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
steer:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
queue:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
},
options: {
interrupt: "중단",
steer: "지시",
queue: "대기열",
},
},
sendShortcut: {
label: "전송 단축키",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "물어보기",
+13 -20
View File
@@ -1365,6 +1365,15 @@ export const ptBR: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Criando espaço de trabalho…",
creatingLabel: "Criando espaço de trabalho",
updateHost: "Atualize este host para criar um espaço de trabalho com um agente.",
chooseProjectHost: "Escolha um projeto e um host",
projectUnavailable: "O projeto não está disponível no host selecionado",
workspacePending: "O espaço de trabalho ainda está sendo criado.",
agentPending: "O agente ainda está sendo criado.",
},
title: "Novo workspace",
create: "Criar",
isolation: {
@@ -2051,13 +2060,12 @@ export const ptBR: TranslationResources = {
output: "Saída",
},
turnSummary: {
details: "Ver detalhes do turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
messages: {
one: "escreveu {{count}} mensagem",
other: "escreveu {{count}} mensagens",
},
},
toolCallGroup: {
thought: "Pensou",
editedFiles: {
one: "editou {{count}} arquivo",
other: "editou {{count}} arquivos",
@@ -2368,27 +2376,12 @@ export const ptBR: TranslationResources = {
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
},
commandEnterDescriptions: {
interrupt:
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
steer:
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
queue:
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
},
options: {
interrupt: "Interromper",
steer: "Orientar",
queue: "Fila",
},
},
sendShortcut: {
label: "Atalho de envio",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Perguntar",
+13 -20
View File
@@ -1357,6 +1357,15 @@ export const ru: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Создание рабочей области…",
creatingLabel: "Создание рабочей области",
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
chooseProjectHost: "Выберите проект и хост",
projectUnavailable: "Проект недоступен на выбранном хосте",
workspacePending: "Рабочая область ещё создаётся.",
agentPending: "Агент ещё создаётся.",
},
title: "Новое рабочее пространство",
create: "Создать",
isolation: {
@@ -2051,13 +2060,12 @@ export const ru: TranslationResources = {
output: "Выходные данные",
},
turnSummary: {
details: "Показать подробности хода",
sentBy: "Отправлено агентом {{name}}",
showMore: "Показать больше",
showLess: "Свернуть",
messages: {
one: "написано {{count}} сообщение",
other: "написаны сообщения ({{count}})",
},
},
toolCallGroup: {
thought: "Обдумал",
editedFiles: {
one: "изменён {{count}} файл",
other: "изменены файлы ({{count}})",
@@ -2371,27 +2379,12 @@ export const ru: TranslationResources = {
queue:
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
},
commandEnterDescriptions: {
interrupt:
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
steer:
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
queue:
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
},
options: {
interrupt: "Прерывать",
steer: "Направить",
queue: "Поставить в очередь",
},
},
sendShortcut: {
label: "Сочетание клавиш для отправки",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Спрашивать",

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