Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 2c83dea127 refactor(app): extract daemon management toggle coordinator with unit tests
Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
2026-05-04 22:33:07 +07:00
Mohamed Boudra 96b39d4c56 test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion
Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).
2026-05-04 22:27:31 +07:00
Mohamed Boudra 13adfee3a4 refactor(app): make use-agent-input-draft storage injectable (#716)
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.

The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
2026-05-04 23:23:36 +08:00
Mohamed Boudra 5387a16bb6 feat(app/e2e): introduce withWorkspace fixture and DSL helpers (#717)
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
2026-05-04 23:22:59 +08:00
Mohamed Boudra 6d7848059e test(app): tighten weak assertions in utils tests (#714)
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
2026-05-04 23:13:09 +08:00
Mohamed Boudra a324ac5d71 refactor(app): extract pr-pane derivations into pure utils and unit tests (#713)
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
2026-05-04 23:13:05 +08:00
Mohamed Boudra 6f04d33b18 refactor(app): assert store state rather than mock call counts in store tests (#715)
Rewrites two store tests to verify observable state instead of implementation-level call counts.

navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.

checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.
2026-05-04 23:06:41 +08:00
Mohamed Boudra 1526532ff4 refactor(app): fix type-aware lint errors in UI components (#710) 2026-05-04 22:14:41 +08:00
Mohamed Boudra dbff649d88 perf(cli): run CLI E2E tests in parallel (#708)
* perf(cli): run E2E tests in parallel via worker pool

The custom CLI test runner ran 35 tsx test files sequentially, making the
cli-tests CI job the longest in main CI (~17 minutes). Each test file
already isolates its own daemon (ephemeral port + tmp PASEO_HOME), so
parallelism was just gated by the runner.

Replace the sequential recursion with a fixed-size worker pool (default
concurrency=4 to match GitHub Actions standard runners; override via
PASEO_CLI_TEST_CONCURRENCY). Buffer per-test stdout/stderr and flush as
a contiguous block on completion so concurrent output stays readable,
and report per-test wall clock plus the five slowest tests.

Local wall clock drops from ~12-15 minutes serial to ~2:49 with
concurrency=4. The slowest single test (05-agent-run, 49s) is now the
floor; CI should land near 4-5 minutes.

* fix(cli-tests): deflake 30-chat under load and shard CI across 3 runners

`chat wait` reads the latest message id and then subscribes for newer
messages. Under CI load the subprocess takes >1s to bootstrap, so the
old test's 250ms head start before posting "second message" raced
against that read. When the post landed first, the subprocess saw the
second message as latest and timed out waiting for a newer one. Replace
the brittle delay with a post-and-race loop: every iteration posts a
fresh "second message" and races a 250ms tick against the wait promise,
so whichever message lands after the snapshot wakes wait deterministically.

CI was also slower than local (10.3min vs 2.8min). On 4-vCPU GHA
runners, 35 sequential-CPU-time of ~1850s caps wall clock around 8 min
even at concurrency=4. Shard the suite across 3 GHA runners via matrix
strategy. The runner now reads PASEO_CLI_TEST_SHARD/SHARD_TOTAL and
distributes files into buckets — known long-pole tests
(05/06/11/13/14-...) round-robin first so they spread across shards
instead of clustering by their numeric prefixes, then the remainder
fills in the reverse direction to balance light load.

Local run is unchanged (single shard by default). Expected per-shard
wall on CI: ~2:30 + setup ≈ ~4:30 total.
2026-05-04 22:14:35 +08:00
Mohamed Boudra a6ad4ff430 refactor(agent-providers): fix type-aware lint errors in diagnostic-utils and generic-acp-agent (#705)
* refactor(agent-providers): add type guards and fix type-aware lint errors in diagnostic-utils, generic-acp-agent, and tool-call-detail-primitives

* fix(server): defer PTY onExit to allow pending data events to fire

On Linux, node-pty's onExit callback can arrive before the last buffered
PTY data chunk is delivered via onData. Wrapping finish() in setImmediate
gives libuv's I/O poll phase a chance to flush remaining PTY reads before
the promise resolves, preventing tail bytes from being silently dropped.

Fixes a flaky worktree-bootstrap test that reliably reproduced on Linux CI.

* unslop: remove dead guard, fix import order, trim comment

- generic-acp-agent: remove isNonEmptyStringArray and its guard; the
  constructor parameter is already typed [string, ...string[]] so the
  check can never fire
- provider-registry: move isNonEmptyStringArray below the import block
  instead of between two import groups
- worktree: condense 3-line comment to one line per project convention
2026-05-04 21:50:23 +08:00
Mohamed Boudra 165fc11955 refactor(app): fix type-aware lint errors in voice, tabs store, host-connection, audio recorder (#706)
Clear all type-aware lint errors in four app state/runtime files:

- voice-context.tsx: bind runtime methods before passing to useSyncExternalStore
  and object spreads to satisfy the unbound-method rule
- workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type
  predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown
  storage data to WorkspaceTabTarget without assertions
- host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace
  String(record.x ?? "") calls with typeof guards to fix no-base-to-string
- use-audio-recorder.native.ts: construct real Blob instead of casting an
  object literal; restructure useEffect for consistent-return; drop await on
  void recorder.record(); use instanceof Error for message extraction; remove
  redundant Boolean() wrapping
2026-05-04 21:50:19 +08:00
Mohamed Boudra 426b81e5d2 refactor(claude-agent): replace unsafe type assertions with type guards (#707)
Adds isObjectRecord, isUnknownArray, isChildProcessWithStreams, and
isImageMimeType type predicates to eliminate all no-unsafe-type-assertion
violations surfaced by typeAware lint mode. Fixes floating promise, adds
throw after exhaustive switch, and widens readCompactionMetadata to accept
unknown so callers need no casts.
2026-05-04 21:50:14 +08:00
Mohamed Boudra 2c90498cb8 refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors (#704)
* refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors in session.ts

* fix build: correct parseClientCapabilities type handling
2026-05-04 21:23:44 +08:00
Mohamed Boudra c42d48a8ff refactor(agent-providers): add toObjectRecord helper and fix type-aware lint errors in codex and claude agents (#699) 2026-05-04 20:30:24 +08:00
Mohamed Boudra 54b1f524b7 refactor(speech): add ONNX type augmentation and fix type-aware lint errors (#701) 2026-05-04 20:29:37 +08:00
Mohamed Boudra 85db9711b3 fix(app): make e2e setup an auto fixture so first-of-spec tests get setup (#702)
* fix(app): make e2e setup an auto fixture so it runs for the first test of every spec

The fixtures.ts beforeEach was declared at the top level of a non-test
fixture file. Playwright sometimes skipped it for the first test of a
subsequent spec when multiple specs ran in the same worker — that test
hit page.evaluate without the seed nonce or daemon registry in
localStorage and failed (then passed on retry, masking the bug behind
retries: 1). Reproduced locally by running sidebar-workspace then
startup-loading: the first startup-loading test got no fixture setup
and threw "Expected e2e seed nonce".

Move the setup into an `auto: true` fixture, the canonical Playwright
pattern for running setup for every test in a workspace. The afterEach
console-attachment is folded into the same fixture's teardown.

* refactor(server): simplify nullish handling in workspace-git-metadata

Drop the explicit `null` arg in `deriveProjectSlug(input.cwd, null)` —
the second parameter already defaults to `null`. Inline the
`repoName && repoName.length > 0` check in `parseGitHubRepoNameFromRemote`
since `pop()` on a non-empty array returns a non-empty string here, and
`|| null` covers the empty-string case directly.
2026-05-04 20:10:26 +08:00
Mohamed Boudra 3c2a98b09b refactor(relay): fix type-aware lint errors in WebSocket and crypto handling (#698) 2026-05-04 19:39:33 +08:00
Mohamed Boudra 9e3ebd9665 schedule: add paseo schedule update to edit schedules in place (#694)
* schedule: add `paseo schedule update` to edit schedules in place

Editing a schedule today requires delete+recreate, losing run history
and the schedule id. This adds an additive RPC and CLI command that
patches name, prompt, cadence, new-agent target fields, max-runs, and
expires-in without touching runs or in-flight executions. nextRunAt is
recomputed only when the cadence actually changes.

* schedule(cli): share cadence flag parser between create and update

Both paths were turning --every/--cron into a ScheduleCadence with
near-identical code. Extract `parseCadenceFromFlags` so the literals
and the exclusivity check live in one place; create wraps it to
require a value, update lets it stay optional.
2026-05-04 19:39:13 +08:00
Mohamed Boudra a1606ea515 fix(server): derive non-GitHub project display names from remote owner/repo (#697)
Reconciliation overwrote correctly-set project display names with the
directory name for non-GitHub remotes (e.g. gitlab.com/acme/app), because
buildWorkspaceGitMetadataFromSnapshot only handled GitHub URLs while the
registry-model layer used the more general deriveProjectGroupingKey path.
Reuse that path here so both layers agree on the owner/repo display name.
2026-05-04 19:38:58 +08:00
Mohamed Boudra fa8fab75aa refactor(app): remove unnecessary String() and Boolean() conversions from type-aware lint fixes (#696) 2026-05-04 18:09:48 +08:00
Mohamed Boudra a3f350a7dc refactor(server): remove unnecessary String() conversions from type-aware lint fixes (#695) 2026-05-04 18:09:43 +08:00
Mohamed Boudra 6a8f507d10 refactor(server): remove redundant null from unknown union types (#693) 2026-05-04 17:23:30 +08:00
Mohamed Boudra ea0bd47c1d refactor(app): remove redundant type constituents from type definitions (#692) 2026-05-04 17:23:09 +08:00
Mohamed Boudra f4dcb3954a refactor(server): replace JSON.parse type assertions with Zod validation (#691)
Replace unsafe 'JSON.parse(content) as Type' patterns with proper Zod schema
validation. This fixes type-aware lint errors and provides runtime safety.

- pid-lock.ts: Add pidLockInfoSchema and parsePidLockInfo helper
- package-version.ts: Add packageJsonSchema and parsePackageJson helper
- relay-transport.ts: Add isRecord type guard to avoid unsafe type assertion
2026-05-04 16:53:40 +08:00
Mathias KurzandMathias Kurz 7fc4dd1e86 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-04 16:51:33 +08:00
Mohamed Boudra 78bb56d90d schedule: fire --every now by default, add run-once for cron-style triggers (#689)
Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.

Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
2026-05-04 16:37:33 +08:00
Mohamed Boudra a788064e32 mcp(create_agent): validate mode and refuse silent cross-provider inheritance (#688)
The MCP create_agent tool inherited the parent agent's mode regardless of
provider, so a Claude parent in bypassPermissions spawning an opencode
child would feed an invalid mode to opencode and the new agent would die
in error state. The fix:

- Add an optional mode field to the agent-to-agent input schema.
- Validate any explicit mode against the target provider's available
  modes; throw with the list when invalid.
- Inherit the caller's mode only when both sides use the same provider;
  otherwise refuse with a helpful error listing the target's modes.
2026-05-04 16:33:19 +08:00
Mohamed Boudra 74f0b9da56 refactor(relay): remove unnecessary awaits from synchronous crypto functions (#687) 2026-05-04 16:33:14 +08:00
Mohamed Boudra aa01f23ab3 feat(cli): paseo worktree create with MCP parity (#686)
Mirrors the MCP create_worktree tool's three modes (branch-off,
checkout-branch, checkout-pr) against the daemon's existing
createPaseoWorktree RPC, so CLI users (especially over --host) can
provision worktrees in the paseo-managed location.
2026-05-04 16:32:50 +08:00
Mohamed Boudra ae3f1bb5df fix(cli): update schedule provider/mode error message substring in test
The error message gained a `--mode` mention when the schedule mode flag
landed; the existing CLI integration test still asserted on the old
exact-prefix substring and broke main CI for all PR builds.
2026-05-04 15:31:23 +07:00
Mohamed Boudra 2064bac918 cli(schedule): require --cwd when --host is set (#685)
process.cwd() is the local path and won't exist on a remote daemon, so
fail fast client-side instead of running the schedule in a wrong dir.
2026-05-04 16:26:55 +08:00
Mohamed Boudra fa4294a0e1 voice: quieter thinking tone, log cleanup, and small ui polish
- Add scripts/lower-thinking-tone.mjs to scale the thinking-tone PCM
  amplitude in-place; apply it at 0.1 so the cue is a soft indicator
  instead of triggering the VAD via mic feedback.
- Drop the "Received first voice_audio_chunk" log that re-fires every
  summary window because the chunk counter is reset.
- Match Spoke message icon and label sizing to the standard tool-call
  badge.
- Append a spoken-input instruction so voice replies route through the
  speak tool.
2026-05-04 14:36:48 +07:00
Mohamed Boudra c993858898 Merge branch 'trace-voice-interrupt-message-gap' 2026-05-04 13:21:55 +07:00
Mohamed Boudra 6e04153b95 Switch voice turn controller to streaming transcription 2026-05-04 13:17:11 +07:00
Mohamed Boudra a6f0d79212 Merge branch 'investigate-opencode-error-swallowing' 2026-05-04 12:49:34 +07:00
Mohamed Boudra 4b2e0357aa fix(opencode): forward provider retries instead of swallowing them
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit
session.status:retry events with messages like "Internal server error"
when the upstream provider returns 5xx. opencode itself retries
indefinitely with backoff and never emits a terminal event for these.

Previously the adapter only surfaced retry messages on a small allowlist
of "fatal" tokens (insufficient balance, invalid api key, etc.) and
silently dropped everything else. The agent appeared hung from the
user's perspective — no message, no spinner update, nothing — until the
upstream eventually recovered or the user manually interrupted.

Forward every session.status:retry as a non-terminal timeline error
item so the user can see what opencode is doing, mirroring opencode's
own TUI. Drop the fatal-token allowlist: classifying which retries are
"really" terminal is opencode's job, and synthesizing turn_failed for
ones we guess at is misleading anyway because opencode keeps spending
upstream while we tell the user the agent is done.

Also a small design pass on the timeline error rendering:
- drop the redundant "Agent error" prefix (the message is descriptive)
- drop the colored box background (visual weight too high for a retry)
- align the icon vertically to the first text line (height+center)
- make the message text selectable so users can copy errors
2026-05-04 12:48:29 +07:00
Mohamed Boudra acf88f4aca Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 12:38:16 +07:00
Mohamed Boudra 412c66ce4b Add --mode to schedule and loop CLI, default background runs to unattended mode
Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
2026-05-04 12:37:54 +07:00
Mohamed Boudra cb906d4223 Type CLI command host/json options at the source
The CommandOptions interface in packages/cli/src/output/with-output.ts had
an open `[key: string]: unknown` indexer, so every consumer of the global
--host and --json options had to cast `options.host as string | undefined`
at the call site. Add `host?: string` and `json?: boolean` to the interface
and remove 29 redundant casts across 12 command files.
2026-05-04 10:51:49 +07:00
github-actions[bot] bf0d3b9786 fix: update lockfile signatures and Nix hash 2026-05-04 03:37:38 +00:00
Mohamed Boudra 7f37255f88 Remove unnecessary type assertions across codebase
Add oxlint-tsgolint and configure typescript/no-unnecessary-type-assertion
to flag redundant `!` and `as Foo` casts. Type-aware mode is left off by
default to keep `npm run lint` fast; the rule sits configured for when we
turn type-aware on intentionally. Auto-fix removed ~283 redundant casts;
two manual touch-ups: a real tsgolint false positive in split-container.tsx
and a stale ChildProcess import after a double-cast collapsed.
2026-05-04 10:21:52 +07:00
Mohamed Boudra 5c03e25232 Drop subagent task notifications from parent timeline
Subagent task_notification system messages arrive without
parent_tool_use_id but with tool_use_id pointing at the parent's Task
call, so they slip past the sidechain router and render as top-level
"Task Notification" rows in the parent's timeline. Skip them when the
referenced tool_use is a Task call; parent-level background bash
notifications still flow through.
2026-05-04 10:13:44 +07:00
Mohamed Boudra b440f44111 Replace fictional fastlane action with Spaceship build-processing poll
The Fastfile called wait_for_build_processing_to_be_complete, which
isn't a built-in fastlane action and isn't published as a plugin
anywhere on rubygems. The submit_review lane has therefore failed
on every release that reached it (v0.1.65-beta.1, v0.1.66, v0.1.67).

Replace it with a direct Spaceship::ConnectAPI poll: fetch the build
matching the latest TestFlight build number, wait until its
processing_state is VALID, then hand off to deliver. 30-minute cap
with a clear failure message on INVALID/FAILED or timeout. Spaceship
ships with fastlane so no plugin or extra gem is required.

Lands in the next release; v0.1.67 needs a manual review submission
from App Store Connect.
2026-05-04 03:00:08 +07:00
github-actions[bot] d433e1a362 fix: update lockfile signatures and Nix hash 2026-05-03 18:45:43 +00:00
169 changed files with 11196 additions and 9294 deletions
-13
View File
@@ -1,18 +1,5 @@
# Changelog
## 0.1.69 - 2026-05-05
### Fixed
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
## 0.1.68 - 2026-05-05
### Fixed
- The desktop app no longer fails on first launch after a fresh install.
## 0.1.67 - 2026-05-03
### Fixed
+2 -6
View File
@@ -1,10 +1,6 @@
# Ad-hoc daemon testing
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
## Quick start
@@ -89,7 +85,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
+2 -2
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-rjzmIH918poK05edU/f6G3iiP16Ra7g7vM0k5HbfKRs=";
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -123,7 +123,7 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -38548,7 +38548,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38674,10 +38674,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.67",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38719,7 +38719,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.67",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38768,7 +38768,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.69",
"version": "0.1.67",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38804,7 +38804,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38830,7 +38830,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38845,12 +38845,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@getpaseo/highlight": "0.1.67",
"@getpaseo/relay": "0.1.67",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -39303,7 +39303,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -1,37 +0,0 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import {
connectArchiveTabDaemonClient,
expectSessionsEmptyState,
openSessions,
} from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectArchiveTabDaemonClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});
-45
View File
@@ -1,45 +0,0 @@
import { test } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
});
try {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
});
try {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -1,284 +0,0 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import {
openAttachmentMenu,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
removeAttachmentPill,
openImageLightbox,
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import {
connectNewWorkspaceDaemonClient,
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
await openGithubPickerFromMenu(page);
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
await expect(
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-issue-",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-pr-",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
});
try {
await fillComposerDraft(page, "queued draft text");
await sendDraftToQueue(page);
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await client.close();
await repo.cleanup();
}
});
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
});
try {
await fillComposerDraft(page, "preserve me");
await pressInterruptShortcut(page);
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await client.close();
await repo.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const repo = await createTempGitRepo("attach-lock-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const daemonClient = await connectNewWorkspaceDaemonClient();
try {
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
agentCreatedDelay.release();
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await daemonClient.close().catch(() => undefined);
await repo.cleanup();
}
});
});
-151
View File
@@ -1,151 +0,0 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopSettings,
expectUpdateBanner,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
toggleDaemonManagement,
expectDaemonManagementConfirmDialog,
expectDaemonManagementEnabled,
expectDaemonManagementDisabled,
expectDaemonStatusPid,
expectDaemonStatusLogPath,
expectDaemonStatusVersion,
} from "./helpers/desktop-updates";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
// No Playwright Electron runner exists; we simulate the desktop bridge via
// addInitScript so Electron-gated UI activates without a real Electron process.
test.describe("Desktop updates", () => {
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
});
test("clicking install shows the installing state on the callout", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getSeededServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
slowInstall: true,
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
});
test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
expectDaemonManagementConfirmDialog(dialogArgs);
await expectDaemonManagementEnabled(page);
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonManagementEnabled(page);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementEnabled(page);
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getSeededServerId();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
});
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: false,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusVersion(page, realState.version);
await expectDaemonStatusPid(page, realState.pid);
await expectDaemonStatusLogPath(page, realState.logPath);
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getSeededServerId();
const realState = await loadRealDaemonState();
await injectDesktopBridge(page, {
serverId,
manageBuiltInDaemon: true,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, null);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
await expectDaemonStatusPid(page, newPid);
});
});
+5 -38
View File
@@ -215,28 +215,8 @@ async function createFakeGhBin(): Promise<string> {
await writeFile(
ghPath,
`#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function findRealGh() {
const fakeBinDir = __dirname;
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir === fakeBinDir) continue;
const candidate = path.join(dir, "gh");
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
}
return null;
}
function forwardToRealGh() {
const realGh = findRealGh();
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
process.exit(result.status ?? 1);
}
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
@@ -258,21 +238,8 @@ if (args[0] === "pr" && args[1] === "list") {
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "api" && args[1] === "graphql") {
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
@@ -280,7 +247,8 @@ if (args[0] === "issue" && args[1] === "list") {
process.exit(0);
}
forwardToRealGh();
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
@@ -605,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
@@ -737,7 +705,6 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
process.env.E2E_PASEO_HOME = paseoHome;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
-18
View File
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
const messages = page.getByTestId("assistant-message");
@@ -16,20 +15,3 @@ export async function awaitToolCall(page: Page, toolName: string | RegExp): Prom
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
}
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
}
export async function expectTurnCopyButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
const { contentHeight } = await readScrollMetrics(page);
await waitForContentGrowth(page, contentHeight);
await expectNearBottom(page);
}
+277 -19
View File
@@ -1,36 +1,217 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
export const gotoAppShell = async (page: Page) => {
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const entryButton = page
.getByText("Add a project", { exact: true })
.or(page.getByText("Add project", { exact: true }))
.or(page.getByText("New agent", { exact: true }))
.first();
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.first()
.isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
await expect
.poll(
async () =>
(await composer.isVisible().catch(() => false)) ||
(await entryButton.isVisible().catch(() => false)),
{ timeout: 10_000 },
)
.toBe(true);
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 },
)
.toBe(true);
if (!(await composer.isVisible().catch(() => false))) {
await entryButton.click();
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
}
await expect(composer).toBeVisible({ timeout: 30_000 });
await expect(composer.first()).toBeVisible({ timeout: 30000 });
};
export const openSettings = async (page: Page) => {
@@ -160,6 +341,46 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
};
export const ensureHostSelected = async (page: Page) => {
await ensureE2EStorageSeeded(page);
// Absolute verification that we're using the per-run e2e daemon (never :6767).
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
// realigning create-agent-preferences.serverId to the sole seeded daemon.
try {
await assertE2EUsesSeededTestDaemon(page);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/create-agent-preferences\.serverId/i.test(message)) {
throw error;
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
if (!fix.ok) {
throw error;
}
await page.reload();
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
@@ -171,7 +392,7 @@ export const ensureHostSelected = async (page: Page) => {
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// We enforce a single seeded daemon, so the option should be unambiguous.
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
@@ -436,3 +657,40 @@ export const createAgentInRepo = async (
await setWorkingDirectory(page, config.directory);
await createAgent(page, config.prompt);
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
// Check if stop button is visible
const isVisible = await stopButton.isVisible().catch(() => false);
if (isVisible) {
// If stop button is still visible after permission denial,
// it might be that the agent is waiting for something.
// Let's check if there's a tool call result or other UI indication
// Look for any indication that the agent has processed the permission denial
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });
}
}
export async function getToolCallCount(page: Page): Promise<number> {
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
// They appear as pressable badges in the agent stream
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
return toolCallBadges.count();
}
+1 -12
View File
@@ -36,9 +36,6 @@ interface ArchiveTabDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}
function getDaemonPort(): string {
@@ -268,10 +265,8 @@ export async function openSessions(page: Page): Promise<void> {
});
}
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
function getSessionRowByTitle(page: Page, title: string) {
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
@@ -288,12 +283,6 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
await row.click();
}
export async function expectSessionsEmptyState(page: Page): Promise<void> {
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
-177
View File
@@ -1,40 +1,9 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export function composerLocator(page: Page) {
return composerInput(page);
}
export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
// not <textarea disabled>. Use not.toBeEditable() to match either form.
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
}
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
}
export async function expectComposerEditable(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -42,154 +11,8 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
await input.press("Enter");
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
});
}
export async function cancelAgent(page: Page): Promise<void> {
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stopButton).toBeVisible({ timeout: 10_000 });
await stopButton.click();
}
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
export async function pressInterruptShortcut(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
await expect(
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
).toBeDisabled({ timeout: 10_000 });
}
export async function attachImageFromMenu(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-image").click();
const chooser = await chooserPromise;
await chooser.setFiles([file]);
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
pillTestId: string,
removeAccessibilityLabel: string,
): Promise<void> {
await page.getByTestId(pillTestId).first().hover();
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
}
export async function expectGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pill = page.getByTestId("composer-github-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 10_000 });
await expect(pill).toContainText(`#${input.number}`);
await expect(pill).toContainText(input.title);
}
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
export async function selectGithubOption(
page: Page,
searchTerm: string,
optionId: string,
): Promise<void> {
await openGithubPickerFromMenu(page);
const searchInput = page.getByPlaceholder("Search issues and PRs...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(searchTerm);
const option = page.getByTestId(`composer-github-option-${optionId}`);
await expect(option).toBeVisible({ timeout: 15_000 });
await option.click();
}
export interface MockAgentSetup {
client: TerminalPerfDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
export async function startRunningMockAgent(
page: Page,
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const repo = await createTempGitRepo(opts.prefix);
const client = await connectTerminalClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
model: opts.model,
initialPrompt: opts.prompt,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
return { client, repo };
}
export interface GithubWorkspaceHandle {
cleanup: () => Promise<void>;
}
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
export async function openGithubWorkspace(
page: Page,
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await openHomeWithProject(page, repoPath);
await selectWorkspaceInSidebar(page, opened.workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
}
+2 -7
View File
@@ -30,15 +30,10 @@ export function buildSeededHost(input: {
};
}
export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" },
} as const;
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}
-289
View File
@@ -1,289 +0,0 @@
import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsHost } from "./settings";
interface DaemonApiStatus {
version: string;
serverId: string;
hostname: string;
}
interface PidFileContent {
pid: number;
desktopManaged: boolean;
}
export interface RealDaemonState {
version: string;
pid: number | null;
logPath: string;
}
/**
* Reads live state from the running E2E test daemon: version from the HTTP
* status endpoint, PID from the paseo.pid lock file, log path from the
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
*/
export async function loadRealDaemonState(): Promise<RealDaemonState> {
const port = process.env.E2E_DAEMON_PORT;
const paseoHome = process.env.E2E_PASEO_HOME;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
const data: DaemonApiStatus = await resp.json();
let pid: number | null = null;
try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
} catch (err) {
// PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err);
}
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
}
export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
daemonVersion?: string | null;
daemonLogPath?: string;
/** Initial manageBuiltInDaemon setting. Defaults to false. */
manageBuiltInDaemon?: boolean;
/**
* Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
}
export interface ConfirmDialogCall {
message: string;
title: string | undefined;
}
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
}
}
/**
* Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
* tracks running state and assigns a fresh PID on each start, letting tests
* observe PID changes without touching the real E2E daemon process.
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
function buildDaemonStatus() {
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: null,
hostname: null,
pid: currentPid,
home: "",
version: cfg.daemonVersion ?? null,
desktopManaged: manageDaemon,
error: null,
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
date: null,
}
: {
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.0.0",
latestVersion: null,
body: null,
date: null,
};
}
if (command === "install_app_update") {
if (cfg.slowInstall) {
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
}
return {
installed: true,
version: cfg.latestVersion ?? "1.2.3",
message: "App update installed. Restart required.",
};
}
if (command === "desktop_daemon_status") {
return buildDaemonStatus();
}
if (command === "desktop_daemon_logs") {
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "patch_desktop_settings") {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
if (command === "stop_desktop_daemon") {
daemonRunning = false;
currentPid = null;
return buildDaemonStatus();
}
if (command === "start_desktop_daemon") {
startCount += 1;
daemonRunning = true;
// First start (bootstrap) returns the configured PID; subsequent starts
// (after a stop) get a fresh PID so tests can observe the change.
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
return buildDaemonStatus();
}
return null;
},
dialog: {
ask: async (message: string, options?: Record<string, unknown>) => {
window.__capturedDialogCall = {
message,
title: typeof options?.title === "string" ? options.title : undefined,
};
return cfg.confirmShouldAccept ?? false;
},
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
}, config);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
export async function expectInstallInProgress(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
}
/**
* Clicks the daemon management switch and waits for dialog.ask to fire in the
* mock, then returns the captured call args (message + title). The mock auto-
* dismisses via confirmShouldAccept=false so callers can assert copy without
* worrying about state changes.
*/
export async function interceptDaemonManagementConfirmDialog(
page: Page,
): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
return page.evaluate(() => window.__capturedDialogCall!);
}
export async function toggleDaemonManagement(
page: Page,
_action: "enable" | "disable",
): Promise<void> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
}
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
expect(args.title).toBe("Pause built-in daemon");
expect(args.message).toContain("stop the built-in daemon immediately");
}
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
}
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
}
/**
* Asserts the daemon status card shows the given PID. Pass null to assert
* the cleared state (shown as "PID —" when the daemon is stopped).
*/
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
const expected = pid !== null ? `PID ${pid}` : "PID —";
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
).toBeVisible();
}
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
await expect(
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
).toBeVisible();
}
/**
* Asserts the host page identity badge shows the given version string.
* The badge is populated from the live WebSocket session's serverInfo.version.
*/
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
await expect(
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
).toBeVisible({ timeout: 15_000 });
}
-244
View File
@@ -1,244 +0,0 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
try {
execSync("gh auth status", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export interface CheckSpec {
context: string;
state: "success" | "failure" | "pending";
}
export interface PrSpec {
title: string;
state: "open" | "merged" | "closed" | "draft";
checks?: CheckSpec[];
commentCount?: number;
}
export interface IssueSpec {
title: string;
body?: string;
labels?: string[];
state?: "open" | "closed";
}
export interface GhPrFixture {
number: number;
title: string;
url: string;
branch: string;
localPath: string;
}
export interface GhIssueFixture {
number: number;
title: string;
url: string;
}
export interface GhRepoFixture {
owner: string;
name: string;
fullName: string;
prs: GhPrFixture[];
issues: GhIssueFixture[];
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function git(args: string[], cwd: string): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
async function seedPr(args: {
spec: PrSpec;
branch: string;
index: number;
basePath: string;
authedUrl: string;
fullName: string;
repoName: string;
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
const createArgs = [
"pr",
"create",
"--title",
spec.title,
"--base",
"main",
"--head",
branch,
"--body",
"",
];
if (spec.state === "draft") createArgs.push("--draft");
const prUrl = gh(createArgs, { cwd: basePath });
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
if (spec.checks && spec.checks.length > 0) {
const sha = git(["rev-parse", branch], basePath);
for (const check of spec.checks) {
gh([
"api",
`repos/${fullName}/statuses/${sha}`,
"--method",
"POST",
"-f",
`state=${check.state}`,
"-f",
`context=${check.context}`,
"-f",
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
]);
}
}
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
}
if (spec.state === "merged") {
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
} else if (spec.state === "closed") {
gh(["pr", "close", String(prNumber)], { cwd: basePath });
}
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
// Clean remote URL (no embedded token) so gh can parse owner/repo
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
git(["config", "user.email", "e2e@paseo.test"], localPath);
git(["config", "user.name", "Paseo E2E"], localPath);
git(["config", "commit.gpgsign", "false"], localPath);
return {
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
localPath,
};
}
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
const { spec, basePath } = args;
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
for (const label of spec.labels ?? []) {
createArgs.push("--label", label);
}
const issueUrl = gh(createArgs, { cwd: basePath });
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
if (spec.state === "closed") {
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
}
return { number: issueNumber, title: spec.title, url: issueUrl };
}
export async function createTempGithubRepo(options: {
prefix?: string;
prs?: PrSpec[];
issues?: IssueSpec[];
}): Promise<GhRepoFixture> {
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${prefix}${uniqueSuffix}`;
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
git(["init", "-b", "main"], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
// Create GitHub repo and push initial commit
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
const owner = gh(["api", "user", "--jq", ".login"]);
const fullName = `${owner}/${repoName}`;
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
// Switch remote to authed URL for subsequent pushes
git(["remote", "set-url", "origin", authedUrl], basePath);
// Create a branch + commit for each PR spec
const branches: string[] = [];
for (let i = 0; i < prs.length; i++) {
const branch = `pr-branch-${i + 1}`;
branches.push(branch);
git(["checkout", "-b", branch], basePath);
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
git(["add", `pr-${i + 1}.txt`], basePath);
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
git(["checkout", "main"], basePath);
}
if (branches.length > 0) {
git(["push", "origin", ...branches], basePath);
}
// Create PRs, seed checks/comments, apply state changes, clone workspaces
const prFixtures: GhPrFixture[] = [];
const localPaths: string[] = [];
for (let i = 0; i < prs.length; i++) {
const { fixture, localPath } = await seedPr({
spec: prs[i],
branch: branches[i],
index: i,
basePath,
authedUrl,
fullName,
repoName,
});
localPaths.push(localPath);
prFixtures.push(fixture);
}
// Create issues
const issueFixtures: GhIssueFixture[] = [];
for (const spec of issues) {
issueFixtures.push(seedIssue({ spec, basePath }));
}
return {
owner,
name: repoName,
fullName,
prs: prFixtures,
issues: issueFixtures,
cleanup: async () => {
try {
gh(["repo", "delete", fullName, "--yes"]);
} catch {
// Best-effort cleanup
}
await Promise.all([
rm(basePath, { recursive: true, force: true }),
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
]);
},
};
}
+20 -14
View File
@@ -68,6 +68,20 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -76,6 +90,11 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
@@ -100,7 +119,7 @@ export async function clickNewChat(page: Page): Promise<void> {
}
/** Click the new terminal button to create a terminal tab. */
export async function clickNewTerminal(page: Page): Promise<void> {
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
@@ -174,19 +193,6 @@ export async function sampleTabsDuringTransition(
return snapshots;
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
const tabTestId = `workspace-tab-agent_${agentId}`;
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
+1 -141
View File
@@ -202,45 +202,12 @@ export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function openBranchPicker(page: Page): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
}
export async function closeBranchPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function expectPickerOpen(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
}
export async function expectPickerClosed(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
timeout: 30_000,
});
}
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toContainText(label);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
@@ -283,110 +250,3 @@ export async function assertNewWorkspaceSidebarAndHeader(
return { workspaceId };
}
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
-42
View File
@@ -1,42 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/utils/pr-pane-data";
export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-pr").click();
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
}
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
}
export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
timeout: 15_000,
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: { passed: number; failed: number; pending: number },
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
}
@@ -1,269 +0,0 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
// --- Navigation ---
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
// action-0 is always "Try again"; action-1 is always "Reload".
// The write-failed callout renders these two buttons in a fixed order.
await page.getByTestId("write-failed-callout-action-0").click();
}
export async function clickReloadProjectSettings(page: Page): Promise<void> {
// Scope to the active error callout so the locator is unambiguous.
// At most one error callout renders at a time.
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
const errorCalloutTestId: Record<ErrorKind, string> = {
stale: "stale-callout",
invalid: "invalid-callout",
write_failed: "write-failed-callout",
transport: "read-transport-callout",
read_failed: "read-failed-callout",
};
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
}
export async function expectNoProjectSettingsError(
page: Page,
kind: ErrorKind,
timeout = 15_000,
): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
}
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
}
// --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 15_000,
});
}
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
}
export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}
// --- Script-list assertions and interactions ---
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
// prefix but contain "-menu-").
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
await expect(
page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
).toHaveCount(count);
}
export async function expectEmptyScriptList(page: Page): Promise<void> {
await expect(page.getByText("No scripts yet.")).toBeVisible();
}
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
const row = page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
.filter({ hasText: scriptName })
.first();
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
// from the row's testID to avoid scoped locator unreliability.
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
await page.getByTestId(`script-row-menu-${id}`).click();
page.once("dialog", (dialog) => void dialog.accept());
await page.getByRole("button", { name: "Remove" }).click();
}
// --- File manipulation ---
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
}
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
const configPath = path.join(repoPath, "paseo.json");
const raw = await readFile(configPath, "utf8");
const config = JSON.parse(raw) as Record<string, unknown>;
config._bump = Date.now();
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
}
export async function restorePaseoConfig(
repoPath: string,
config: Record<string, unknown>,
): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
}
// The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o555);
}
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o755);
}
// --- WebSocket helpers ---
function buildDaemonPortPattern(): RegExp {
const port = process.env.E2E_DAEMON_PORT;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
}
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (armed && typeof message === "string") {
try {
const envelope = JSON.parse(message) as {
type?: string;
message?: { type?: string };
};
if (
envelope.type === "session" &&
envelope.message?.type === "read_project_config_request"
) {
armed = false;
void ws.close({ code: 1001 });
return;
}
} catch {
// binary or malformed frame — pass through
}
}
try {
server.send(message);
} catch {
// server socket already closed
}
});
server.onMessage((message) => {
try {
ws.send(message);
} catch {
// client socket already closed
}
});
});
}
// Installs a transparent WS proxy that can later drop all active daemon connections
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
// in DaemonClient due to describeTransportClose returning a non-empty string.
export async function installDaemonConnectionGate(
page: Page,
): Promise<{ drop: () => Promise<void> }> {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1001 });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) return;
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) return;
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
},
};
}
-186
View File
@@ -1,9 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { requireServerId } from "./sidebar";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const SECTION_LABELS = {
general: "General",
@@ -55,184 +50,3 @@ export async function selectHostConnectionType(
export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
}
export async function expectSettingsSidebarSections(
page: Page,
sections: Array<Exclude<SettingsSection, "projects">>,
): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) {
await expect(
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
).toBeVisible();
}
}
export async function goBackInSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
}
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
const serverId = requireServerId();
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expectHostSettingsUrl(page, serverId);
}
export async function openCompactSettingsHost(page: Page): Promise<void> {
const serverId = requireServerId();
await openSettingsHost(page, serverId);
await expectHostSettingsUrl(page, serverId);
}
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
}
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
await page.getByTestId("direct-host-uri-input").fill(uri);
}
export async function expectDirectHostFormValues(
page: Page,
fields: { host: string; port: string; password: string },
): Promise<void> {
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
}
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
}
export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
}
export async function expectAboutContent(page: Page): Promise<void> {
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
}
export async function expectGeneralContent(page: Page): Promise<void> {
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
}
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
export async function clickEditHostLabel(page: Page): Promise<void> {
await page.getByTestId("host-page-label-edit-button").click();
}
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
}
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
}
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
const card = page.getByTestId("host-page-inject-mcp-card");
await expect(card).toBeVisible();
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function expectHostActionCards(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
}
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
}
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
}
+1 -19
View File
@@ -1,6 +1,6 @@
import { expect, type Page } from "@playwright/test";
export function requireServerId(): string {
function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
@@ -19,21 +19,3 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click();
}
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click({ force: true });
}
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
}
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
+1 -29
View File
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
@@ -263,34 +263,6 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
);
}
export async function expectTerminalSurfaceVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
timeout: options?.timeout ?? 20_000,
});
}
export async function focusTerminalSurface(page: Page): Promise<void> {
await expectTerminalSurfaceVisible(page);
await page.locator('[data-testid="terminal-surface"]').first().click();
}
export async function typeInTerminal(page: Page, text: string): Promise<void> {
await page
.locator('[data-testid="terminal-surface"]')
.first()
.pressSequentially(text, { delay: 0 });
}
export async function waitForTerminalAttached(page: Page): Promise<void> {
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => undefined);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickNewTerminal } from "./launcher";
import { clickNewChat, clickTerminal } from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
}
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
await clickNewTerminal(page);
await clickTerminal(page);
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
}
@@ -6,7 +6,6 @@ import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { switchWorkspaceViaSidebar } from "./workspace-ui";
import type { SessionOutboundMessage } from "@server/shared/messages";
interface WorkspaceSetupDaemonClient {
@@ -316,30 +315,6 @@ export async function fetchWorkspaceById(
return workspace;
}
export async function navigateToWorkspaceViaSidebar(
page: Page,
workspaceId: string,
): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
}
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
}
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-scripts-menu-backdrop").click();
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
@@ -70,29 +70,6 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
}
}
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
}
export async function expectNoTerminalTabs(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
}
export async function clickFirstTerminalTab(
page: Page,
options?: { timeout?: number },
): Promise<void> {
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
await tab.click();
}
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
text,
);
}
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {},
-43
View File
@@ -114,49 +114,6 @@ export async function expectWorkspaceHeader(
});
}
export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
}
export async function expectReconnectingToastGone(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: options?.timeout ?? 30_000,
});
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
}
export async function expectMenuButtonVisible(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button")).toBeVisible();
}
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
}
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
}
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 });
+36 -33
View File
@@ -5,18 +5,16 @@ import {
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickNewTerminal,
clickTerminal,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
terminalSurfaceLocator,
} from "./helpers/launcher";
import { expectComposerVisible, composerLocator } from "./helpers/composer";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import {
connectTerminalClient,
setupDeterministicPrompt,
@@ -52,7 +50,9 @@ test.describe("Tab creation", () => {
await pressNewTabShortcut(page);
await expectComposerVisible(page);
// Should show the composer directly (no launcher panel)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
@@ -77,9 +77,11 @@ test.describe("Tab creation", () => {
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await clickNewTabButton(page);
await expectComposerVisible(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
const tabsAfter = await getTabTestIds(page);
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
@@ -90,9 +92,11 @@ test.describe("Tab creation", () => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
const tabsAfter = await getTabTestIds(page);
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
@@ -137,18 +141,19 @@ test.describe("Terminal title propagation", () => {
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
delay: 0,
});
await terminal
.first()
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
@@ -166,24 +171,24 @@ test.describe("Terminal title propagation", () => {
try {
await gotoWorkspace(page, workspaceId);
await clickNewTerminal(page);
await clickTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
delay: 0,
});
await terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminalSurfaceLocator(page).pressSequentially(
`printf '\\033]0;${finalTitle}\\007'\n`,
{ delay: 0 },
);
await terminal
.first()
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
@@ -223,10 +228,11 @@ test.describe("Tab transitions (no flash)", () => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
() => clickTerminal(page),
terminal.first(),
20_000,
);
@@ -239,12 +245,9 @@ test.describe("Tab transitions (no flash)", () => {
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
+104 -58
View File
@@ -1,29 +1,22 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill,
expectPickerClosed,
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
@@ -35,6 +28,108 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -410,55 +505,6 @@ test.describe("New workspace flow", () => {
}
});
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await selectPickerOptionByKeyboard(page, "dev");
await expectPickerSelected(page, "dev");
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
const tempRepo = await createTempGitRepo("picker-escape-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await closeBranchPicker(page);
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
-124
View File
@@ -1,124 +0,0 @@
import { test } from "./fixtures";
import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
} from "./helpers/pr-pane";
import { gotoWorkspace } from "./helpers/launcher";
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
const GITHUB_AUTH = hasGithubAuth();
test.describe("PR pane", () => {
test.describe.configure({ retries: 1 });
let seedClient: WorkspaceSetupDaemonClient;
let repoFixture: GhRepoFixture;
const workspaceByTitle = new Map<string, string>();
test.beforeAll(async () => {
if (!GITHUB_AUTH) return;
seedClient = await connectWorkspaceSetupClient();
repoFixture = await createTempGithubRepo({
prefix: "paseo-e2e-pr-",
prs: [
{ title: "Review selected start ref", state: "open" },
{ title: "Merged feature branch", state: "merged" },
{ title: "Closed without merge", state: "closed" },
{ title: "Work in progress", state: "draft" },
{
title: "PR with mixed checks",
state: "open",
checks: [
{ context: "build-1", state: "success" },
{ context: "build-2", state: "success" },
{ context: "deploy", state: "failure" },
{ context: "security", state: "pending" },
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
for (const pr of repoFixture.prs) {
const result = await seedClient.openProject(pr.localPath);
if (!result.workspace) {
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
}
workspaceByTitle.set(pr.title, result.workspace.id);
}
});
test.afterAll(async () => {
await repoFixture?.cleanup().catch(() => undefined);
await seedClient?.close().catch(() => undefined);
});
test.beforeEach(async () => {
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
test.setTimeout(60_000);
});
test("renders an open PR with title, state, and repo line", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
await openPrPane(page);
await expectPrPaneTitle(page, "Review selected start ref");
await expectPrPaneState(page, "open");
});
test("renders merged state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
await openPrPane(page);
await expectPrPaneState(page, "merged");
await expectPrPaneTitle(page, "Merged feature branch");
});
test("renders closed state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
await openPrPane(page);
await expectPrPaneState(page, "closed");
await expectPrPaneTitle(page, "Closed without merge");
});
test("renders draft state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
await openPrPane(page);
await expectPrPaneState(page, "draft");
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
});
test("renders activity rows with correct count", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
});
});
+29 -166
View File
@@ -1,36 +1,9 @@
import { chmod, readFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base } from "./fixtures";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
const updatedSetup = ["npm install", "npm run build"];
@@ -75,9 +48,6 @@ const test = base.extend<ProjectsSettingsFixtures>({
});
await client.close();
// Defensive: restore directory write permission in case the test left it blocked
// (write_failed test), so that repo.cleanup() can remove files inside.
await chmod(repo.path, 0o755).catch(() => undefined);
await repo.cleanup();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
@@ -98,6 +68,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
@@ -139,7 +133,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
@@ -151,138 +145,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
});
test.describe("Projects settings — error UX", () => {
test("stale-write callout appears on save, disables save, and reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Bump the file on disk so the daemon detects a revision mismatch on save.
await bumpPaseoConfigOnDisk(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "stale");
await expectSaveButtonDisabled(page);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "stale");
await expectProjectSettingsFormVisible(page);
});
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
page,
editableProject,
}) => {
await corruptPaseoConfig(editableProject.path);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "invalid");
await expectProjectSettingsFormHidden(page);
// Restore a valid config so the reload succeeds.
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "invalid");
await expectProjectSettingsFormVisible(page);
});
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await blockPaseoConfigWrites(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "write_failed");
await expectWriteFailedCalloutActions(page);
await clickRetryProjectSettingsSave(page);
await expectProjectSettingsError(page, "write_failed");
await unblockPaseoConfigWrites(editableProject.path);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "write_failed");
await expectProjectSettingsFormVisible(page);
});
test("read-transport failure shows callout, reload recovers", async ({
page,
editableProject,
}) => {
// Drop the WS connection the moment a read_project_config_request is sent.
// Subsequent connections are proxied transparently so Reload can succeed.
await installReadTransportFailure(page);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "transport");
await expectProjectSettingsFormHidden(page);
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
await expect(async () => {
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "transport", 3_000);
}).toPass({ timeout: 15_000 });
await expectProjectSettingsFormVisible(page);
});
test("project settings shows no-target state when daemon connection drops", async ({
page,
editableProject,
}) => {
const gate = await installDaemonConnectionGate(page);
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
// The NoEditableTarget UI renders via isHostGone check regardless of state.
await gate.drop();
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
});
});
+67 -30
View File
@@ -1,20 +1,7 @@
import { test } from "./fixtures";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import {
expectSettingsHeader,
openSettingsHost,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
expectLocalHostEntryFirst,
} from "./helpers/settings";
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
@@ -32,6 +19,12 @@ function getSeededDaemonPort(): string {
return port;
}
async function expectHostLabelHeader(page: Page) {
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
@@ -43,11 +36,26 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostConnectionsCard(page, port);
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card");
await expect(connectionsCard).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -57,9 +65,13 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await page.getByTestId("host-page-label-edit-button").click();
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
@@ -72,14 +84,28 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
// Pair-device now lives behind a row that only the local host sees
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
// not appear. The daemon-lifecycle card is still local-host only.
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
@@ -90,10 +116,9 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
@@ -135,6 +160,18 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await expectLocalHostEntryFirst(page, serverId);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15000 });
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
await expect(hostEntries.first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
});
});
+147 -85
View File
@@ -1,60 +1,62 @@
import { test, expect } from "./fixtures";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
selectHostConnectionType,
toggleHostAdvanced,
openCompactSettings,
expectCompactSettingsList,
expectSettingsSidebarVisible,
expectSettingsSidebarHidden,
expectSettingsSidebarSections,
goBackInSettings,
expectSettingsBackButton,
clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect,
openCompactSettingsHost,
expectAddHostMethodOptions,
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
} from "./helpers/settings";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
async function clickSidebarSection(page: Page, label: string) {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await sidebar.getByRole("button", { name: label, exact: true }).click();
}
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "diagnostics");
await expectSettingsHeader(page, "Diagnostics");
await expectDiagnosticsContent(page);
await clickSidebarSection(page, "Diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
await openSettingsSection(page, "about");
await expectSettingsHeader(page, "About");
await expectAboutContent(page);
await clickSidebarSection(page, "About");
await expect(page).toHaveURL(/\/settings\/about$/);
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
await openSettingsSection(page, "general");
await expectSettingsHeader(page, "General");
await expectGeneralContent(page);
await clickSidebarSection(page, "General");
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
});
test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await expectAddHostMethodOptions(page);
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
@@ -62,34 +64,38 @@ test.describe("Settings sidebar navigation", () => {
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
await expectDirectHostFormValues(page, {
host: "example.paseo.test",
port: "7443",
password: "shared-secret",
});
await expectDirectHostSslEnabled(page);
await expectDirectHostUriHidden(page);
await page.getByTestId("direct-host-advanced-toggle").click();
await page
.getByTestId("direct-host-uri-input")
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await page.getByTestId("direct-host-advanced-toggle").click();
await toggleHostAdvanced(page);
await expectDirectHostUriValue(
page,
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
);
await toggleHostAdvanced(page);
await expectDirectHostUriHidden(page);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
const backRow = page.getByTestId("settings-back-to-workspace");
await expect(backRow).toBeVisible();
await backRow.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
@@ -97,57 +103,113 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
async function openCompactSettingsRoot(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page);
// Wait for bootstrap to settle on a host route so storeReady is true before
// we navigate into the protected settings stack. A direct page.goto("/settings")
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
// Drive navigation the same way a user would: open the mobile drawer and tap
// the Settings footer icon. This preserves the in-app router state instead of
// triggering a full reload through Stack.Protected.
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(sidebarSettingsButton).toBeVisible();
await sidebarSettingsButton.click();
await expectSettingsBackButton(page);
await goBackInSettings(page);
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
async function expectCompactSettingsRootList(page: Page) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page);
// Sidebar rows are present.
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
).toBeVisible();
// Section detail content is NOT rendered at the root.
await expectCompactSettingsRootList(page);
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettingsRoot(page);
await openSettingsSection(page, "diagnostics");
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
// Sidebar is no longer visible — we are on a detail screen.
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
// only visible instances.)
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettingsRoot(page);
await openSettingsSection(page, "about");
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "About", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/about$/);
await goBackInSettings(page);
await expectCompactSettingsList(page);
await expectSettingsBackButton(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expectCompactSettingsRootList(page);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
const serverId = getServerId();
await openCompactSettingsRoot(page);
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
await expectSettingsSidebarHidden(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
});
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
const serverId = getServerId();
await openCompactSettingsRoot(page);
await openCompactSettingsHost(page);
await goBackInSettings(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
});
});
@@ -1,12 +1,12 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
@@ -17,15 +17,12 @@ function getServerId(): string {
return serverId;
}
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
async function pressSettingsToggleShortcut(page: Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
}
async function expectSendBehavior(
page: import("@playwright/test").Page,
expected: "interrupt" | "queue",
) {
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
@@ -38,11 +35,12 @@ async function expectSendBehavior(
}
async function openAgentRouteAndExpectFocused(input: {
page: import("@playwright/test").Page;
page: Page;
serverId: string;
workspaceId: string;
agentId: string;
}) {
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
await input.page.goto(
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
);
@@ -51,7 +49,10 @@ async function openAgentRouteAndExpectFocused(input: {
{ timeout: 60_000 },
);
await waitForTabBar(input.page);
await expectAgentTabActive(input.page, input.agentId);
await expect(
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
}
test.describe("Settings toggle tab regression", () => {
@@ -78,7 +79,13 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(/\/settings\/general$/);
@@ -91,11 +98,19 @@ test.describe("Settings toggle tab regression", () => {
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
await page.reload();
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
@@ -137,10 +152,14 @@ test.describe("Settings toggle tab regression", () => {
agentId: secondAgent.id,
});
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
await page.reload();
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
await expect(
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
}
} finally {
for (const agentId of agentIds) {
@@ -4,12 +4,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
@@ -186,16 +180,3 @@ test.describe("Sidebar workspace list", () => {
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("showMobileAgent open and close transition", async ({ page }) => {
await gotoAppShell(page);
await expectMobileAgentSidebarHidden(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
});
});
+116 -32
View File
@@ -1,45 +1,129 @@
import { test } from "./fixtures";
import { clickNewTerminal } from "./helpers/launcher";
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
typeInTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
} from "./helpers/terminal-perf";
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickNewTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickNewTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
@@ -1,7 +1,7 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
@@ -15,26 +15,17 @@ import {
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import {
getVisibleWorkspaceAgentTabIds,
expectOnlyWorkspaceAgentTabsVisible,
waitForWorkspaceTabsVisible,
expectWorkspaceTabsAbsent,
} from "./helpers/workspace-tabs";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
expectWorkspaceHeaderAbsent,
expectMenuButtonVisible,
expectHostConnectingOrOffline,
expectReconnectingToastVisible,
expectReconnectingToastGone,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
workspaceDeckEntryLocator,
expectWorkspaceDeckEntryCount,
} from "./helpers/workspace-ui";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -71,10 +62,6 @@ async function expectNoLoadingWorkspacePane(
}
}
async function expectNoLoadingPane(page: Page): Promise<void> {
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
@@ -172,31 +159,35 @@ test.describe("Workspace navigation regression", () => {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await expectReconnectingToastVisible(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
await expectNoLoadingPane(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
label: "host reconnect",
});
daemonGate.restore();
await expectReconnectingToastGone(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000,
});
await monitorReconnect;
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
} finally {
daemonGate.restore();
for (const agentId of agentIds) {
@@ -231,11 +222,16 @@ test.describe("Workspace navigation regression", () => {
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expectHostConnectingOrOffline(page);
await expectMenuButtonVisible(page);
await expectWorkspaceHeaderAbsent(page);
await expectWorkspaceTabsAbsent(page);
await openSettings(page);
await expect(
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("menu-button")).toBeVisible();
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
await settingsButton.click();
await expect(page).toHaveURL(/\/settings\/general$/);
});
@@ -274,13 +270,6 @@ test.describe("Workspace navigation regression", () => {
await waitForSidebarHydration(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
const secondDeckEntry = workspaceDeckEntryLocator(
page,
serverId,
secondWorkspace.workspaceId,
);
await switchWorkspaceViaSidebar({
page,
serverId,
@@ -311,6 +300,13 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${firstAgent.id}`,
]);
const firstDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
);
const secondDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({
@@ -346,7 +342,7 @@ test.describe("Workspace navigation regression", () => {
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId: targetServerId }) => {
@@ -375,7 +371,7 @@ test.describe("Workspace navigation regression", () => {
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
@@ -389,7 +385,7 @@ test.describe("Workspace navigation regression", () => {
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(secondDeckEntry).toBeAttached();
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.reload();
await waitForSidebarHydration(page);
@@ -1,16 +1,32 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { clickNewTerminal } from "./helpers/launcher";
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
findWorktreeWorkspaceForProject,
navigateToWorkspaceViaSidebar,
openHomeWithProject,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace setup runtime authority", () => {
test.describe.configure({ retries: 1 });
@@ -32,6 +48,7 @@ test.describe("Workspace setup runtime authority", () => {
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
// Navigate to the workspace via sidebar
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
@@ -69,8 +86,10 @@ test.describe("Workspace setup runtime authority", () => {
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Verify terminal is listed under the worktree directory, not the original repo
await expect
@@ -1,28 +1,23 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
waitForWorkspaceTabsVisible,
expectNoTerminalTabs,
clickFirstTerminalTab,
expectFirstTerminalTabContains,
} from "./helpers/workspace-tabs";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer";
import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
navigateToWorkspaceViaSidebar,
openWorkspaceScriptsMenu,
startWorkspaceScriptFromMenu,
closeWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
interface WorkspaceScriptStarter {
startWorkspaceScript(
workspaceId: string,
@@ -35,6 +30,18 @@ interface WorkspaceScriptStarter {
}>;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
@@ -93,15 +100,31 @@ test.describe("Workspace setup streaming", () => {
});
await completed;
// Navigate to workspace and verify it's usable
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await expectComposerVisible(page, { timeout: 30_000 });
await openFileExplorer(page);
await expectExplorerEntryVisible(page, "README.md");
await expectExplorerEntryVisible(page, "src");
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
@@ -250,14 +273,33 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await expectNoTerminalTabs(page);
await openWorkspaceScriptsMenu(page);
await startWorkspaceScriptFromMenu(page, "web");
await closeWorkspaceScriptsMenu(page);
await clickFirstTerminalTab(page);
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
await waitForTerminalAttached(page);
await expectFirstTerminalTabContains(page, "web");
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("workspace-scripts-start-web").click();
await page.getByTestId("workspace-scripts-menu-backdrop").click();
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
await terminalTab.click();
// Verify the terminal surface rendered
const terminalSurface = page.getByTestId("terminal-surface").first();
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
// Wait for terminal to fully attach (loading overlay gone)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
await expect(terminalTab).toContainText("web");
} finally {
await client.close();
await repo.cleanup();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"main": "index.ts",
"scripts": {
@@ -9,12 +9,8 @@ import type {
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
interface WorkspaceAttachmentBindingInput {
@@ -73,6 +69,31 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
});
}
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
@@ -282,7 +303,7 @@ function WorkspaceAttachmentPill({
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
toSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
@@ -1,35 +0,0 @@
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment } from "@server/shared/messages";
export function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
export function userAttachmentsOnly(
attachments: readonly ComposerAttachment[],
): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
export function workspaceAttachmentToSubmitAttachment(
attachment: ComposerAttachment,
): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -11,8 +11,6 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
return "Change model";
case "mode":
return "Change permission mode";
default:
throw new Error("unreachable");
}
}
@@ -1,180 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { NeighborResolver } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
// Minimal forward-order resolver: "below" = next item in array.
// Matches web strategy rendering order (chronological, top-to-bottom).
const forwardStrategy: NeighborResolver = {
getNeighborItem(items, index, relation) {
const neighborIndex = relation === "below" ? index + 1 : index - 1;
return items[neighborIndex];
},
};
function assistantBlock(params: {
id: string;
blockGroupId: string;
blockIndex: number;
text?: string;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: params.blockGroupId,
blockIndex: params.blockIndex,
text: params.text ?? "",
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "cmd",
result: null,
status: "executing",
},
},
};
}
describe("isSameAssistantBlockGroup", () => {
it("returns true for two assistant blocks with the same blockGroupId", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
});
it("returns false for blocks from different groups", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
});
it("returns false when one item is not an assistant_message", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const tc = toolCallBlock("tc-1");
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
});
it("returns false for null neighbors", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
});
});
describe("getAssistantBlockSpacing", () => {
it("returns default for non-assistant items", () => {
const tc = toolCallBlock("tc-1");
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
"default",
);
});
it("returns default when no same-group neighbors exist", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
"compactTop",
);
});
it("returns compactBottom when the item below is in the same block group", () => {
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
"compactBottom",
);
});
it("returns compactBoth when both neighbors are in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
"compactBoth",
);
});
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
text: "First paragraph",
});
const headBlock = assistantBlock({
id: "group-1:head",
blockGroupId: "group-1",
blockIndex: 1,
text: "Second paragraph",
});
expect(
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
).toBe("compactBottom");
expect(
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
).toBe("compactTop");
});
});
describe("resolveInlineWorkingIndicatorItemId", () => {
it("returns null when the agent is not running", () => {
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
});
it("returns the last assistant block id when running with a single head block", () => {
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
"group-1:head",
);
});
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
const tc = toolCallBlock("tool-1");
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
});
it("returns the footer assistant block when history and streaming head coexist", () => {
const historyBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
});
const streamingBlock = assistantBlock({
id: "group-2:head",
blockGroupId: "group-2",
blockIndex: 0,
});
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
"group-2:head",
);
expect(
resolveInlineWorkingIndicatorItemId(
"running",
[historyBlock, streamingBlock],
forwardStrategy,
),
).toBe("group-2:head");
});
});
@@ -1,51 +0,0 @@
import type { StreamItem } from "@/types/stream";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
other: StreamItem | null | undefined;
}): boolean {
return (
params.item?.kind === "assistant_message" &&
params.other?.kind === "assistant_message" &&
params.item.blockGroupId !== undefined &&
params.item.blockGroupId === params.other.blockGroupId
);
}
export function getAssistantBlockSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
return "default";
}
export interface NeighborResolver {
getNeighborItem(
items: StreamItem[],
index: number,
relation: "above" | "below",
): StreamItem | undefined;
}
// null → auxiliary working indicator; non-null → inline footer on that block.
export function resolveInlineWorkingIndicatorItemId(
status: string,
liveHeadItems: StreamItem[],
strategy: NeighborResolver,
): string | null {
if (status !== "running") return null;
const footerItem = liveHeadItems.find((item, index, items) => {
if (item.kind !== "assistant_message") return false;
return strategy.getNeighborItem(items, index, "below") === undefined;
});
return footerItem?.id ?? null;
}
@@ -0,0 +1,419 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import { AgentStreamView } from "./agent-stream-view";
const assistantMessageCalls = vi.hoisted(
() => [] as Array<{ message: string; spacing: string | undefined }>,
);
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
const mockSessionState = vi.hoisted(() => ({
sessions: {
server: {
client: null,
agentStreamHead: new Map<string, StreamItem[]>(),
workspaces: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
},
},
setAgentTimelineOlderFetchInFlight: () => {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (
factory: (theme: {
borderRadius: Record<string, number>;
borderWidth: Record<number, number>;
colors: Record<string, string>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
shadow: Record<string, object>;
spacing: Record<number, number>;
}) => unknown,
) =>
factory({
borderRadius: {
full: 9999,
md: 6,
},
borderWidth: {
1: 1,
},
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
border: "#333",
borderAccent: "#444",
},
fontSize: {
sm: 14,
base: 16,
xs: 12,
},
fontWeight: {
normal: "normal",
},
shadow: {
sm: {},
},
spacing: {
1: 4,
2: 8,
3: 12,
4: 16,
12: 48,
},
}),
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: (Component: unknown) => Component,
}));
vi.mock("react-native-reanimated", async () => {
const ReactModule = await import("react");
return {
default: {
View: ({ children, ...props }: { children?: React.ReactNode }) =>
ReactModule.createElement("div", props, children),
},
Easing: { linear: vi.fn() },
FadeIn: { duration: vi.fn(() => undefined) },
FadeOut: { duration: vi.fn(() => undefined) },
cancelAnimation: vi.fn(),
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
withRepeat: (value: unknown) => value,
withTiming: (value: unknown) => value,
};
});
vi.mock("lucide-react-native", async () => {
const ReactModule = await import("react");
const Icon = () => ReactModule.createElement("span");
return {
Check: Icon,
ChevronDown: Icon,
X: Icon,
};
});
vi.mock("./message", async () => {
const ReactModule = await import("react");
return {
ActivityLog: () => null,
AssistantMessage: (props: { message: string; spacing?: string }) => {
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
return ReactModule.createElement("div", {
"data-message": props.message,
"data-spacing": props.spacing ?? "",
});
},
CompactionMarker: () => null,
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
SpeakMessage: () => null,
TodoListCard: () => null,
ToolCall: () => null,
TurnCopyButton: (props: { getContent: () => string }) => {
turnCopyButtonCalls.push(props);
return ReactModule.createElement("button", {
"data-testid": "turn-copy-button",
type: "button",
});
},
UserMessage: () => null,
};
});
vi.mock("./tool-call-sheet", async () => {
const ReactModule = await import("react");
return {
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
useToolCallSheet: () => ({ open: vi.fn() }),
};
});
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
vi.mock("@/hooks/use-file-explorer-actions", () => ({
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
}));
vi.mock("@/stores/panel-store", () => ({
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
openFileExplorerForCheckout: vi.fn(),
setExplorerTabForCheckout: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: Object.assign(
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
{
getState: () => mockSessionState,
},
),
}));
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
function assistantBlock(params: {
id: string;
text: string;
blockIndex: number;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: "group-1",
blockIndex: params.blockIndex,
text: params.text,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "npm test",
result: null,
status: "executing",
},
},
};
}
describe("AgentStreamView", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
assistantMessageCalls.length = 0;
turnCopyButtonCalls.length = 0;
mockSessionState.sessions.server.agentStreamHead = new Map();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
vi.restoreAllMocks();
});
it("compacts assistant block spacing across the history/live-head boundary", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "First paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-1:head",
text: "Second paragraph",
blockIndex: 1,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
const streamItems = [tailBlock];
const pendingPermissions = new Map();
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems,
pendingPermissions,
}),
);
});
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
expect(tailCalls.length).toBeGreaterThan(0);
expect(headCalls.length).toBeGreaterThan(0);
expect(tailCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: tailCalls.length }, () => "compactBottom"),
);
expect(headCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: headCalls.length }, () => "compactTop"),
);
});
it("renders running dots in the assistant turn footer when live text is streaming", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
});
it("only renders running dots on the live assistant row", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "History paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-2:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [tailBlock],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
});
it("keeps the auxiliary running dots when there is no live assistant row", () => {
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).not.toBeNull();
});
it("replaces the running footer with the copy button when the assistant turn idles", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Complete paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
);
});
});
@@ -71,11 +71,6 @@ import {
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
@@ -97,6 +92,43 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
const isSameAssistantBlockGroup = (params: {
item: StreamItem | null | undefined;
other: StreamItem | null | undefined;
}) =>
params.item?.kind === "assistant_message" &&
params.other?.kind === "assistant_message" &&
params.item.blockGroupId !== undefined &&
params.item.blockGroupId === params.other.blockGroupId;
const getAssistantBlockSpacing = (params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" => {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({
item: params.item,
other: params.aboveItem,
});
const compactBottom = isSameAssistantBlockGroup({
item: params.item,
other: params.belowItem,
});
if (compactTop && compactBottom) {
return "compactBoth";
}
if (compactTop) {
return "compactTop";
}
if (compactBottom) {
return "compactBottom";
}
return "default";
};
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
@@ -269,15 +301,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(
() =>
resolveInlineWorkingIndicatorItemId(
agent.status,
baseRenderModel.segments.liveHead,
streamRenderStrategy,
),
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
);
const inlineWorkingIndicatorItemId = useMemo(() => {
if (agent.status !== "running") {
return null;
}
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
if (item.kind !== "assistant_message") {
return false;
}
return (
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) === undefined
);
});
return footerItem?.id ?? null;
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
useImperativeHandle(
ref,
() => ({
@@ -20,7 +20,7 @@ const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
function noop() {}
@@ -446,11 +446,12 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus]);
const inputStyle = useMemo(
@@ -698,12 +699,12 @@ export function CombinedModelSelector({
useEffect(() => {
if (platformIsWeb) {
return () => {};
return;
}
if (!isOpen) {
setIsContentReady(false);
return () => {};
return;
}
const frame = requestAnimationFrame(() => {
@@ -790,7 +791,7 @@ export function CombinedModelSelector({
)}
</Pressable>
<Combobox
options={EMPTY_COMBOBOX_OPTIONS}
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
value=""
onSelect={noop}
open={isOpen}
@@ -1,715 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { StreamItem } from "@/types/stream";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findGithubItemByOption,
isAttachmentSelectedForGithubItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
type AgentStreamWriter,
type AttachmentPersister,
type ComposerCancelClient,
type ComposerSendClient,
type QueueWriter,
type QueuedComposerMessage,
} from "./composer-actions";
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
state: "open",
body: "Issue body",
labels: ["composer"],
baseRefName: null,
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
state: "open",
body: "PR body",
labels: ["composer"],
baseRefName: "main",
headRefName: "composer-attachments",
};
function imageWithId(id: string): AttachmentMetadata {
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
}
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
const attachment: Extract<AgentAttachment, { type: "review" }> = {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment,
};
}
function browserElementWorkspaceAttachment(): Extract<
WorkspaceComposerAttachment,
{ kind: "browser_element" }
> {
return {
kind: "browser_element",
attachment: {
url: "https://example.com/page",
selector: "button.primary",
tag: "button",
text: "Save",
outerHTML: '<button class="primary">Save</button>',
computedStyles: { display: "flex" },
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
reactSource: null,
parentChain: ["form.settings"],
children: [],
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
},
};
}
function createFakePersister(): AttachmentPersister & {
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
deletedBatches: AttachmentMetadata[][];
} {
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
const deletedBatches: AttachmentMetadata[][] = [];
return {
blobCalls,
fileUriCalls,
deletedBatches,
persistFromBlob: async ({ blob, mimeType, fileName }) => {
blobCalls.push({ blob, mimeType, fileName });
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
},
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
fileUriCalls.push({ uri, mimeType, fileName });
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
},
deleteAttachments: (metadata) => {
deletedBatches.push(metadata);
},
};
}
interface FakeSendCall {
agentId: string;
text: string;
options: {
messageId: string;
images: Array<{ data: string; mimeType: string }>;
attachments: AgentAttachment[];
};
}
function createFakeSendClient(
options: { rejection?: Error } = {},
): ComposerSendClient & { calls: FakeSendCall[] } {
const calls: FakeSendCall[] = [];
return {
calls,
sendAgentMessage: async (agentId, text, opts) => {
calls.push({ agentId, text, options: opts });
if (options.rejection) {
throw options.rejection;
}
},
};
}
interface FakeStream extends AgentStreamWriter {
head: Map<string, StreamItem[]>;
tail: Map<string, StreamItem[]>;
}
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
},
setTail: (updater) => {
fake.tail = updater(fake.tail);
},
};
return fake;
}
function createFakeQueue(
initial: Map<string, QueuedComposerMessage[]> = new Map(),
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
state: new Map(initial),
read: (agentId) => fake.state.get(agentId) ?? [],
write: (updater) => {
fake.state = updater(fake.state);
},
};
return fake;
}
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
describe("cancelComposerAgent", () => {
function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] };
agentId: string;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
} {
const canceledIds: string[] = [];
return {
client: {
canceledIds,
cancelAgent: async (id) => {
canceledIds.push(id);
},
},
agentId: "agent",
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
};
}
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
const input = baseInput();
const result = cancelComposerAgent(input);
expect(result).toBe(true);
expect(input.client.canceledIds).toEqual(["agent"]);
});
it("does nothing when the agent is not running", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
expect(result).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when the agent is already being canceled", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
expect(result).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when disconnected or the client is null", () => {
const input = baseInput();
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
});
describe("pickAndPersistImages", () => {
it("returns [] when the picker yields nothing", async () => {
const persister = createFakePersister();
const result = await pickAndPersistImages({
pickImages: async () => null,
persister,
});
expect(result).toEqual([]);
expect(persister.blobCalls).toEqual([]);
expect(persister.fileUriCalls).toEqual([]);
});
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
const persister = createFakePersister();
const blob = new Blob(["image"]);
const result = await pickAndPersistImages({
pickImages: async () => [
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
],
persister,
});
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
});
it("persists file_uri sources via persistFromFileUri", async () => {
const persister = createFakePersister();
const result = await pickAndPersistImages({
pickImages: async () => [
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
],
persister,
});
expect(persister.fileUriCalls).toEqual([
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
]);
expect(result).toHaveLength(1);
});
});
describe("dispatchComposerAgentMessage", () => {
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const image = imageWithId("img-2");
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "send attachments",
attachments: [
{ kind: "image", metadata: image },
{ kind: "github_pr", item: prItem },
],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls).toHaveLength(1);
const [call] = client.calls;
expect(call.agentId).toBe("agent");
expect(call.text).toBe("send attachments");
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
expect(call.options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
]);
expect(stream.head.get("agent")).toBeUndefined();
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
expect(userMessage.kind).toBe("user_message");
expect(userMessage.text).toBe("send attachments");
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
});
it("appends to the existing head when one is present", async () => {
const existingItem: StreamItem = {
kind: "user_message",
id: "prior",
text: "prior",
timestamp: new Date(0),
};
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
const client = createFakeSendClient();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "next message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
});
expect(stream.head.get("agent")).toHaveLength(2);
expect(stream.tail.get("agent")).toBeUndefined();
});
it("submits empty wire arrays when no attachments are provided", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "plain message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options).toMatchObject({
images: [],
attachments: [],
});
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const review = reviewWorkspaceAttachment("Please simplify this.");
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "review this",
attachments: [review],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
expect(client.calls[0]?.options.images).toEqual([]);
});
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const browserElement = browserElementWorkspaceAttachment();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "inspect element",
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([
{
type: "text",
mimeType: "text/plain",
title: "Browser element · button",
text: browserElement.attachment.formatted,
},
]);
});
});
describe("queueComposerMessage", () => {
it("queues a trimmed message under the agent id and returns the new entry", () => {
const queue = createFakeQueue();
const result = queueComposerMessage({
agentId: "agent",
text: " draft ",
attachments: [],
queue,
});
expect(result.queued?.text).toBe("draft");
expect(queue.state.get("agent")).toEqual([
{ id: result.queued?.id, text: "draft", attachments: [] },
]);
});
it("does not queue an empty message with no attachments", () => {
const queue = createFakeQueue();
const result = queueComposerMessage({
agentId: "agent",
text: " ",
attachments: [],
queue,
});
expect(result.queued).toBeNull();
expect(queue.state.get("agent")).toBeUndefined();
});
it("captures workspace review attachments at queue time alongside user attachments", () => {
const queue = createFakeQueue();
const review = reviewWorkspaceAttachment("Initial queued review.");
const image = imageWithId("img-queue");
queueComposerMessage({
agentId: "agent",
text: "queue this",
attachments: [{ kind: "image", metadata: image }, review],
queue,
});
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
{ kind: "image", metadata: image },
review,
]);
});
});
describe("editQueuedComposerMessage", () => {
it("returns null and leaves the queue untouched when the message id is missing", () => {
const queue = createFakeQueue(
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
);
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
expect(result).toBeNull();
expect(queue.state.get("agent")).toHaveLength(1);
});
it("returns the text and only user attachments, removing the queued entry", () => {
const review = reviewWorkspaceAttachment("Queued snapshot.");
const image = imageWithId("img-queued-edit");
const queue = createFakeQueue(
new Map([
[
"agent",
[
{
id: "msg-1",
text: "queued draft",
attachments: [{ kind: "image", metadata: image }, review],
},
],
],
]),
);
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
expect(result).toEqual({
text: "queued draft",
attachments: [{ kind: "image", metadata: image }],
});
expect(queue.state.get("agent")).toEqual([]);
});
});
describe("sendQueuedComposerMessageNow", () => {
it("returns missing without submitting when the message id is gone", async () => {
const queue = createFakeQueue();
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "missing" });
expect(submitted).toEqual([]);
});
it("removes the queued entry and submits its text + attachments", async () => {
const review = reviewWorkspaceAttachment("Queued for send.");
const queue = createFakeQueue(
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
);
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "submitted" });
expect(queue.state.get("agent")).toEqual([]);
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue(
new Map([
[
"agent",
[
{ id: "msg-1", text: "first", attachments: [] },
{ id: "msg-2", text: "second", attachments: [] },
],
],
]),
);
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async () => {
throw new Error("network down");
},
});
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
const state = queue.state.get("agent");
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
});
});
describe("removeComposerAttachmentAtIndex", () => {
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
const image = imageWithId("img-remove");
const persister = createFakePersister();
const next = removeComposerAttachmentAtIndex({
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
index: 0,
deleteAttachments: persister.deleteAttachments,
});
expect(next).toEqual([]);
expect(persister.deletedBatches).toEqual([[image]]);
});
it("removes a github attachment without scheduling any storage deletes", () => {
const persister = createFakePersister();
const next = removeComposerAttachmentAtIndex({
attachments: [
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
] satisfies UserComposerAttachment[],
index: 0,
deleteAttachments: persister.deleteAttachments,
});
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
expect(persister.deletedBatches).toEqual([]);
});
});
describe("openComposerAttachment", () => {
it("opens the lightbox for image attachments", () => {
const image = imageWithId("img-body");
const lightboxCalls: AttachmentMetadata[] = [];
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: { kind: "image", metadata: image },
setLightboxMetadata: (metadata) => {
lightboxCalls.push(metadata);
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(lightboxCalls).toEqual([image]);
expect(externalUrlCalls).toEqual([]);
});
it("delegates workspace review attachments to the workspace opener", () => {
const review = reviewWorkspaceAttachment("Open me.");
const workspaceCalls: ComposerAttachment[] = [];
openComposerAttachment({
attachment: review,
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: ({ attachment }) => {
workspaceCalls.push(attachment);
return true;
},
openExternalUrl: () => {
throw new Error("unexpected external url call");
},
});
expect(workspaceCalls).toEqual([review]);
});
it("opens GitHub item URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: { kind: "github_issue", item: issueItem },
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
});
describe("toggleGithubAttachment", () => {
it("appends a GitHub issue when not already attached", () => {
const next = toggleGithubAttachment([], issueItem);
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
});
it("appends a GitHub PR when not already attached", () => {
const next = toggleGithubAttachment([], prItem);
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("removes an existing GitHub item with the same kind+number", () => {
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
expect(next).toEqual([]);
});
it("does not affect other items with different kind or number", () => {
const start: UserComposerAttachment[] = [
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
];
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
const next = toggleGithubAttachment(start, otherIssue);
expect(next).toEqual([
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
{ kind: "github_issue", item: otherIssue },
]);
});
});
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
});
it("recognizes when an attachment list already contains a matching GitHub item", () => {
const attachments: ComposerAttachment[] = [
{ kind: "image", metadata: imageWithId("img-x") },
{ kind: "github_issue", item: issueItem },
reviewWorkspaceAttachment("ignored"),
];
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
});
});
@@ -1,325 +0,0 @@
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { generateMessageId, type StreamItem } from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
export interface QueuedComposerMessage {
id: string;
text: string;
attachments: ComposerAttachment[];
}
export interface AttachmentPersister {
persistFromBlob: (input: {
blob: Blob;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
persistFromFileUri: (input: {
uri: string;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
}
export interface ComposerSendClient {
sendAgentMessage: (
agentId: string,
text: string,
options: {
messageId: string;
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
}
export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | void;
}
export interface AgentStreamWriter {
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
}
export interface QueueWriter {
read: (agentId: string) => QueuedComposerMessage[];
write: (
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
) => void;
}
export async function pickAndPersistImages(input: {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
}): Promise<AttachmentMetadata[]> {
const result = await input.pickImages();
if (!result?.length) return [];
return await Promise.all(
result.map(async (picked) => {
const fileName = picked.fileName ?? null;
const mimeType = picked.mimeType || "image/jpeg";
if (picked.source.kind === "blob") {
return await input.persister.persistFromBlob({
blob: picked.source.blob,
mimeType,
fileName,
});
}
return await input.persister.persistFromFileUri({
uri: picked.source.uri,
mimeType,
fileName,
});
}),
);
}
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
attachments: T[];
index: number;
deleteAttachments: AttachmentPersister["deleteAttachments"];
}): T[] {
const removed = input.attachments[input.index];
if (removed?.kind === "image") {
void input.deleteAttachments([removed.metadata]);
}
return input.attachments.filter((_, i) => i !== input.index);
}
export interface CancelComposerAgentInput {
client: ComposerCancelClient | null;
agentId: string;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
if (!input.isAgentRunning || input.isCancellingAgent) return false;
if (!input.isConnected || !input.client) return false;
void input.client.cancelAgent(input.agentId);
return true;
}
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
text: string;
attachments: ComposerAttachment[];
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
stream: AgentStreamWriter;
}
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const messageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
text: input.text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
}
function appendUserMessageToStream(
agentId: string,
userMessage: StreamItem,
stream: AgentStreamWriter,
): void {
const head = stream.getHead(agentId);
if (head && head.length > 0) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
return;
}
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
}
export interface QueueComposerMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
queue: QueueWriter;
}
export interface QueueComposerMessageResult {
queued: QueuedComposerMessage | null;
}
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
const trimmed = input.text.trim();
if (!trimmed && input.attachments.length === 0) {
return { queued: null };
}
const item: QueuedComposerMessage = {
id: generateMessageId(),
text: trimmed,
attachments: input.attachments,
};
input.queue.write((prev) => {
const next = new Map(prev);
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
return next;
});
return { queued: item };
}
export interface EditQueuedComposerMessageInput {
agentId: string;
messageId: string;
queue: QueueWriter;
}
export interface EditQueuedComposerMessageResult {
text: string;
attachments: UserComposerAttachment[];
}
export function editQueuedComposerMessage(
input: EditQueuedComposerMessageInput,
): EditQueuedComposerMessageResult | null {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return null;
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
input.agentId,
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
);
return next;
});
return {
text: item.text,
attachments: userAttachmentsOnly(item.attachments),
};
}
export interface SendQueuedComposerMessageNowInput {
agentId: string;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
}
export type SendQueuedComposerMessageNowResult =
| { status: "missing" }
| { status: "submitted" }
| { status: "failed"; errorMessage: string };
export async function sendQueuedComposerMessageNow(
input: SendQueuedComposerMessageNowInput,
): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" };
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
input.agentId,
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
);
return next;
});
try {
await input.submitMessage({ text: item.text, attachments: item.attachments });
return { status: "submitted" };
} catch (error) {
input.queue.write((prev) => {
const next = new Map(prev);
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
return next;
});
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : "Failed to send message",
};
}
}
export interface OpenComposerAttachmentInput {
attachment: ComposerAttachment;
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
openExternalUrl: (url: string) => void;
}
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
if (input.attachment.kind === "image") {
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (isWorkspaceAttachment(input.attachment)) {
input.openWorkspaceAttachment({ attachment: input.attachment });
return;
}
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
export function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
@@ -1,8 +1,5 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import {
isWorkspaceAttachment,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
@@ -21,8 +18,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (isWorkspaceAttachment(attachment)) {
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (composerWorkspaceAttachment.is(attachment)) {
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
}
File diff suppressed because it is too large Load Diff
+231 -86
View File
@@ -23,6 +23,7 @@ import {
import Animated from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
AgentStatusBar,
DraftAgentStatusBar,
@@ -30,6 +31,7 @@ import {
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
MessageInput,
@@ -42,22 +44,6 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findGithubItemByOption,
isAttachmentSelectedForGithubItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/components/composer-actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -93,12 +79,17 @@ import type {
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
type QueuedMessage = QueuedComposerMessage;
interface QueuedMessage {
id: string;
text: string;
attachments: ComposerAttachment[];
}
type AttachmentListUpdater =
| UserComposerAttachment[]
@@ -137,6 +128,37 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
async function pickAndPersistImages(
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
): Promise<ImageAttachment[]> {
const result = await pickImages();
if (!result?.length) return [];
return await Promise.all(
result.map(async (pickedImage) => {
if (pickedImage.source.kind === "blob") {
return await persistAttachmentFromBlob({
blob: pickedImage.source.blob,
mimeType: pickedImage.mimeType || "image/jpeg",
fileName: pickedImage.fileName ?? null,
});
}
return await persistAttachmentFromFileUri({
uri: pickedImage.source.uri,
mimeType: pickedImage.mimeType || "image/jpeg",
fileName: pickedImage.fileName ?? null,
});
}),
);
}
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
@@ -215,6 +237,45 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
}
function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return composerWorkspaceAttachment
.userAttachmentsOnly(current)
.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
}
interface RenderAttachmentPreviewListArgs {
selectedAttachments: ComposerAttachment[];
isComposerLocked: boolean;
@@ -355,6 +416,104 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
});
}
interface DispatchAgentMessageSendArgs {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
serverId: string;
targetAgentId: string;
text: string;
sendAttachments: ComposerAttachment[];
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
}
function appendUserMessageToStream(
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
): void {
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
const currentHead = useSessionStore
.getState()
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
if (currentHead && currentHead.length > 0) {
setAgentStreamHead(serverId, (prev) => {
const head = prev.get(targetAgentId) || [];
const updated = new Map(prev);
updated.set(targetAgentId, [...head, userMessage]);
return updated;
});
return;
}
setAgentStreamTail(serverId, (prev) => {
const currentStream = prev.get(targetAgentId) || [];
const updated = new Map(prev);
updated.set(targetAgentId, [...currentStream, userMessage]);
return updated;
});
}
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
const { client, targetAgentId, text, sendAttachments } = args;
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
const clientMessageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: clientMessageId,
text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream({ ...args, userMessage });
const imagesData = await encodeImages(wirePayload.images);
await client.sendAgentMessage(targetAgentId, text, {
messageId: clientMessageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
}
function openComposerAttachment(
attachment: ComposerAttachment,
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
): void {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
if (composerWorkspaceAttachment.is(attachment)) {
openWorkspaceAttachment({ attachment });
return;
}
void openExternalUrl(attachment.item.url);
}
interface CancelRunningAgentArgs {
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
agentIdRef: { current: string };
setIsCancellingAgent: (value: boolean) => void;
messageInputRef: { current: MessageInputRef | null };
}
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
const {
isAgentRunning,
isCancellingAgent,
isConnected,
client,
agentIdRef,
setIsCancellingAgent,
messageInputRef,
} = args;
if (!isAgentRunning || isCancellingAgent) return;
if (!isConnected || !client) return;
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
function focusMessageInputWithPlatformStrategy(messageInputRef: {
current: MessageInputRef | null;
}): void {
@@ -461,7 +620,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
accessibilityRole="button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
@@ -469,7 +627,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
accessibilityRole="button"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable>
@@ -995,18 +1152,14 @@ export function Composer({
if (!client) {
throw new Error("Host is not connected");
}
const stream: AgentStreamWriter = {
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
await dispatchComposerAgentMessage({
await dispatchAgentMessageSend({
client,
agentId: targetAgentId,
serverId,
targetAgentId,
text,
attachments: sendAttachments,
encodeImages,
stream,
sendAttachments,
setAgentStreamHead,
setAgentStreamTail,
});
onAttentionPromptSend?.();
};
@@ -1019,23 +1172,33 @@ export function Composer({
const isAgentRunning = agentState.status === "running";
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 updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, updater(prev.get(agentId) ?? []));
return next;
});
},
[agentId, serverId, setQueuedMessages],
);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
const result = queueComposerMessage({
agentId,
text: queuedMessage,
const trimmedMessage = queuedMessage.trim();
if (!trimmedMessage && queuedAttachments.length === 0) return;
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
attachments: queuedAttachments,
queue: queueWriter,
};
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next;
});
if (!result.queued) return;
setUserInput("");
setSelectedAttachments([]);
@@ -1045,8 +1208,9 @@ export function Composer({
[
agentId,
clearSentAttachments,
queueWriter,
resetSuppression,
serverId,
setQueuedMessages,
setSelectedAttachments,
setUserInput,
],
@@ -1120,15 +1284,7 @@ export function Composer({
);
const handlePickImage = useCallback(async () => {
const newImages = await pickAndPersistImages({
pickImages,
persister: {
persistFromBlob: ({ blob, mimeType, fileName }) =>
persistAttachmentFromBlob({ blob, mimeType, fileName }),
persistFromFileUri: ({ uri, mimeType, fileName }) =>
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
},
});
const newImages = await pickAndPersistImages(pickImages);
if (newImages.length === 0) return;
addImages(newImages);
}, [addImages, pickImages]);
@@ -1142,23 +1298,14 @@ export function Composer({
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) =>
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment({
attachment,
setLightboxMetadata,
openWorkspaceAttachment: openAttachment,
openExternalUrl: (url) => {
void openExternalUrl(url);
},
});
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
},
[openAttachment],
);
@@ -1170,16 +1317,15 @@ export function Composer({
}, [isAgentRunning, isConnected]);
const handleCancelAgent = useCallback(() => {
const didCancel = cancelComposerAgent({
client,
agentId: agentIdRef.current,
cancelRunningAgent({
isAgentRunning,
isCancellingAgent,
isConnected,
client,
agentIdRef,
setIsCancellingAgent,
messageInputRef,
});
if (!didCancel) return;
setIsCancellingAgent(true);
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
const focusMessageInputForKeyboardAction = useCallback(() => {
@@ -1245,34 +1391,33 @@ export function Composer({
const handleEditQueuedMessage = useCallback(
(id: string) => {
const result = editQueuedComposerMessage({
agentId,
messageId: id,
queue: queueWriter,
});
if (!result) return;
setUserInput(result.text);
setSelectedAttachments(result.attachments);
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
},
[agentId, queueWriter, setSelectedAttachments, setUserInput],
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
);
const handleSendQueuedNow = useCallback(
async (id: string) => {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
// Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({
agentId,
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
});
if (result.status === "failed") {
setSendError(result.errorMessage);
try {
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
}
},
[agentId, queueWriter, submitMessage],
[queuedMessages, submitMessage, updateQueue],
);
const handleQueue = useCallback(
@@ -0,0 +1,249 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
const hoistedPanelState = {
isOpen: false,
showMobileAgent: vi.fn(),
};
return {
panelState: hoistedPanelState,
useSidebarWorkspacesListMock: vi.fn(),
theme: {
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
colors: {
surfaceSidebar: "#111",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#444",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#666",
accent: "#0a84ff",
accentForeground: "#fff",
palette: {
green: { 400: "#30d158" },
amber: { 500: "#ffd60a" },
red: { 500: "#ff453a" },
},
},
},
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
absoluteFillObject: {},
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Extrapolation: { CLAMP: "clamp" },
interpolate: () => 0,
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
}));
vi.mock("react-native-gesture-handler", () => {
const chain = {
enabled: () => chain,
hitSlop: () => chain,
manualActivation: () => chain,
onTouchesDown: () => chain,
onTouchesMove: () => chain,
onStart: () => chain,
onUpdate: () => chain,
onEnd: () => chain,
onFinalize: () => chain,
withRef: () => chain,
};
return {
Gesture: { Pan: () => chain },
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
FolderPlus: createIcon("FolderPlus"),
MessagesSquare: createIcon("MessagesSquare"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
};
});
vi.mock("expo-router", () => ({
router: { push: vi.fn() },
usePathname: () => "/hosts/srv",
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => true,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/stores/panel-store", () => ({
MIN_SIDEBAR_WIDTH: 260,
MAX_SIDEBAR_WIDTH: 420,
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "srv", label: "Local" }],
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
}));
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
}));
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
useSidebarShortcutModel: () => ({
collapsedProjectKeys: new Set<string>(),
shortcutIndexByWorkspaceKey: new Map<string, number>(),
toggleProjectCollapsed: vi.fn(),
}),
}));
vi.mock("@/contexts/sidebar-animation-context", () => ({
useSidebarAnimation: () => ({
translateX: { value: 0 },
backdropOpacity: { value: 0 },
windowWidth: 390,
animateToOpen: vi.fn(),
animateToClose: vi.fn(),
isGesturing: { value: false },
gestureAnimatingRef: { current: false },
closeGestureRef: { current: undefined },
}),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/utils/desktop-window", () => ({
useWindowControlsPadding: () => ({ top: 0 }),
}));
vi.mock("@/utils/host-routes", () => ({
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
buildSettingsRoute: () => "/settings",
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
parseServerIdFromPathname: () => "srv",
}));
vi.mock("@/hooks/use-open-project-picker", () => ({
useOpenProjectPicker: () => vi.fn(),
}));
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.mock("./sidebar-workspace-list", () => ({
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
React.createElement(
"div",
{ "data-testid": "sidebar-workspace-list" },
projects.map((project) => project.projectName).join(","),
),
}));
vi.mock("./sidebar-agent-list-skeleton", () => ({
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => React.createElement("span", null),
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: () => null,
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.stubGlobal("React", React);
import { LeftSidebar } from "./left-sidebar";
describe("LeftSidebar", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
panelState.isOpen = false;
panelState.showMobileAgent.mockReset();
useSidebarWorkspacesListMock.mockReset();
useSidebarWorkspacesListMock.mockReturnValue({
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
isInitialLoad: false,
isRevalidating: false,
refreshAll: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
await act(async () => {
root?.render(<LeftSidebar />);
});
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
serverId: "srv",
enabled: true,
});
});
});
+1 -1
View File
@@ -308,7 +308,7 @@ function HostPickerTrigger({
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
Boolean(hovered) && styles.hostTriggerHovered,
],
[],
);
@@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeCanStartDictation } from "./message-input-state";
const connected = { isConnected: true } as never;
const disconnected = { isConnected: false } as never;
describe("computeCanStartDictation", () => {
it("returns false when socket is disconnected", () => {
expect(
computeCanStartDictation({
client: disconnected,
isReadyForDictation: true,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(false);
});
it("returns false when isReadyForDictation is explicitly false", () => {
expect(
computeCanStartDictation({
client: connected,
isReadyForDictation: false,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(false);
});
it("returns true when connected and ready", () => {
expect(
computeCanStartDictation({
client: connected,
isReadyForDictation: true,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(true);
});
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
expect(
computeCanStartDictation({
client: connected,
isReadyForDictation: undefined,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(true);
expect(
computeCanStartDictation({
client: disconnected,
isReadyForDictation: undefined,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(false);
});
it("returns false when the input is disabled", () => {
expect(
computeCanStartDictation({
client: connected,
isReadyForDictation: true,
disabled: true,
dictationUnavailableMessage: null,
}),
).toBe(false);
});
it("returns false when a dictation unavailable message is present", () => {
expect(
computeCanStartDictation({
client: connected,
isReadyForDictation: true,
disabled: false,
dictationUnavailableMessage: "Microphone unavailable",
}),
).toBe(false);
});
it("returns false when client is null", () => {
expect(
computeCanStartDictation({
client: null,
isReadyForDictation: true,
disabled: false,
dictationUnavailableMessage: null,
}),
).toBe(false);
});
});
@@ -1,14 +0,0 @@
import type { DaemonClient } from "@server/client/daemon-client";
export function computeCanStartDictation(input: {
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
disabled: boolean;
dictationUnavailableMessage: string | null | undefined;
}): boolean {
const socketConnected = input.client?.isConnected ?? false;
const readyForDictation = input.isReadyForDictation ?? socketConnected;
return (
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
);
}
@@ -0,0 +1,394 @@
import React, { createRef } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
const EMPTY_ATTACHMENTS: React.ComponentProps<typeof MessageInput>["attachments"] = [];
const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = [];
const FAKE_CONNECTED_CLIENT = { isConnected: true } as never;
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
startDictationMock: vi.fn(),
cancelDictationMock: vi.fn(),
confirmDictationMock: vi.fn(),
}));
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
shadow: { md: {} },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158" },
},
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
}));
vi.mock("@/constants/platform", () => ({
isWeb: false,
isNative: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: startDictationMock,
cancelDictation: cancelDictationMock,
confirmDictation: confirmDictationMock,
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
selector({ sessions: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock(
"@/hooks/use-web-scrollbar-style",
() => ({
useWebScrollbarStyle: () => undefined,
}),
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
{ virtual: true },
);
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
TooltipTrigger: ({
asChild,
children,
...props
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
} & Record<string, unknown>) =>
asChild ? (
children
) : (
<button type="button" aria-label={props.accessibilityLabel as string | undefined}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => children,
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
}) => (
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: false })
: children}
</button>
),
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
startDictationMock.mockReset();
cancelDictationMock.mockReset();
confirmDictationMock.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
interface RenderMessageInputOptions {
value?: string;
submitIcon?: "arrow" | "return";
}
function renderMessageInput(
menuItems: AttachmentMenuItem[],
{ value = "", submitIcon }: RenderMessageInputOptions = {},
) {
act(() => {
root?.render(
<MessageInput
value={value}
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={menuItems}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()}
/>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
return document.querySelectorAll(`[aria-label="${label}"]`);
}
describe("MessageInput attachments", () => {
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
const menuItems: AttachmentMenuItem[] = [
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
];
renderMessageInput(menuItems);
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
const attachButton = queryByTestId("message-input-attach-button");
expect(attachButton).not.toBeNull();
click(attachButton!);
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
expect(
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
).toHaveLength(2);
});
it("selecting Attach image invokes the supplied image attachment action", () => {
const attachImage = vi.fn();
renderMessageInput([
{ id: "image", label: "Attach image", onSelect: attachImage },
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
]);
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
expect(attachImage).toHaveBeenCalledTimes(1);
});
it("does not render the old queue button", () => {
renderMessageInput([
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
]);
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
});
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
renderMessageInput([], { value: "Send this" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
renderMessageInput([], { value: "Create this", submitIcon: "return" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
});
});
describe("MessageInput dictation shortcuts", () => {
it("does not poison the dictation toggle when readiness is temporarily false", () => {
const inputRef = createRef<MessageInputRef>();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).not.toHaveBeenCalled();
expect(confirmDictationMock).not.toHaveBeenCalled();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).toHaveBeenCalledTimes(1);
expect(confirmDictationMock).not.toHaveBeenCalled();
});
});
+13 -1
View File
@@ -53,7 +53,6 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror";
import { computeCanStartDictation } from "./message-input-state";
export type ImageAttachment = AttachmentMetadata;
@@ -969,6 +968,19 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
}
function computeCanStartDictation(input: {
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
disabled: boolean;
dictationUnavailableMessage: string | null | undefined;
}): boolean {
const socketConnected = input.client?.isConnected ?? false;
const readyForDictation = input.isReadyForDictation ?? socketConnected;
return (
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
);
}
function computeIsDictationStartEnabled(
isReadyForDictation: boolean | undefined,
isConnected: boolean,
+6 -17
View File
@@ -91,17 +91,15 @@ export function PrPane({ data }: { data: PrPaneData }) {
);
return (
<View style={styles.root} testID="pr-pane">
<View style={styles.root}>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={stateLabelStyle} testID="pr-pane-state">
{stateLabel}
</Text>
<Text style={stateLabelStyle}>{stateLabel}</Text>
</View>
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
<Text style={styles.title} numberOfLines={3}>
{data.title}
{hovered ? (
<Text>
@@ -126,19 +124,12 @@ export function PrPane({ data }: { data: PrPaneData }) {
count={passed}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
testID="pr-pane-check-failed"
/>
<SummaryPill count={failed} color={theme.colors.statusDanger} icon={checkDangerIcon} />
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={checkWarningIcon}
testID="pr-pane-check-pending"
/>
</>
}
@@ -220,17 +211,15 @@ function SummaryPill({
count,
color,
icon,
testID,
}: {
count: number;
color: string;
icon: React.ReactNode;
testID?: string;
}) {
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
<View style={styles.summaryPill}>
{icon}
<Text style={textStyle}>{count}</Text>
</View>
@@ -275,7 +264,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
[item.avatarColor],
);
return (
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
<Pressable onPress={handlePress} style={activityPressableStyle}>
<View style={avatarStyle}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>
@@ -72,7 +72,7 @@ function AutocompleteRow({
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
(Boolean(hovered) || pressed || isSelected) && styles.itemActive,
],
[isSelected],
);
@@ -315,7 +315,7 @@ export function DropdownMenuTrigger({
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
},
@@ -324,7 +324,7 @@ export function DropdownMenuTrigger({
const renderChildren = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
const state: TriggerState = { pressed, hovered, open: ctx.open };
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
return typeof children === "function" ? children(state) : children;
},
[children, ctx.open],
@@ -445,7 +445,7 @@ export function DropdownMenuContent({
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return undefined;
return;
}
// Capture status bar height synchronously before async measurement.
@@ -454,8 +454,8 @@ export function DropdownMenuContent({
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (cancelled) return undefined;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
// On Android with statusBarTranslucent, measureInWindow returns coordinates
// relative to below the status bar, but Modal content starts from screen top.
// Add status bar height to align coordinate systems (same as react-native-popover-view).
@@ -463,7 +463,7 @@ export function DropdownMenuContent({
...rect,
y: rect.y + statusBarHeight,
});
return undefined;
return;
});
return () => {
@@ -32,7 +32,6 @@ vi.mock("react-native-reanimated", () => ({
vi.mock("react-native", () => ({
Pressable: ({
"aria-checked": ariaChecked,
accessibilityLabel,
accessibilityRole,
accessibilityState,
@@ -41,7 +40,6 @@ vi.mock("react-native", () => ({
onPress,
testID,
}: {
"aria-checked"?: boolean;
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean };
@@ -53,7 +51,7 @@ vi.mock("react-native", () => ({
React.createElement(
"button",
{
"aria-checked": ariaChecked ?? accessibilityState?.checked,
"aria-checked": accessibilityState?.checked,
"aria-disabled": accessibilityState?.disabled,
"aria-label": accessibilityLabel,
"data-disabled": disabled,
@@ -100,7 +100,6 @@ export function Switch({
accessibilityRole="switch"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
aria-checked={value}
testID={testID}
style={pressableStyle}
>
+27 -17
View File
@@ -865,7 +865,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, [client, isConnected, serverId]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
if (!voiceRuntime) {
return;
}
return voiceRuntime.registerSession({
serverId,
setVoiceMode: async (enabled, agentId) => {
if (!client) {
@@ -895,7 +899,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setIsPlayingAudio(serverId, isPlaying);
},
});
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
useEffect(() => {
@@ -913,7 +916,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useEffect(() => {
if (!client || !isConnected) {
return () => {};
return;
}
let cancelled = false;
@@ -1239,10 +1242,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: message.payload.id,
workspaceId: String(message.payload.id),
});
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
removeWorkspace(serverId, message.payload.id);
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
removeWorkspace(serverId, String(message.payload.id));
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
@@ -1400,10 +1403,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "tool_call" && data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const toolName = typeof data.metadata.toolName === "string" ? data.metadata.toolName : "";
const args = data.metadata.arguments;
const {
toolCallId,
toolName,
arguments: args,
} = data.metadata as {
toolCallId: string;
toolName: string;
arguments: unknown;
};
setMessages(serverId, (prev) => [
...prev,
@@ -1420,9 +1428,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "tool_result" && data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const result = data.metadata.result;
const { toolCallId, result } = data.metadata as {
toolCallId: string;
result: unknown;
};
const applyToolResult = applyToolResultToMessages(toolCallId, result);
setMessages(serverId, applyToolResult);
@@ -1430,9 +1439,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const error = data.metadata.error;
const { toolCallId, error } = data.metadata as {
toolCallId: string;
error: unknown;
};
const applyToolError = applyToolErrorToMessages(toolCallId, error);
setMessages(serverId, applyToolError);
@@ -1790,7 +1800,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} catch (error) {
console.error("[Session] Failed to prepare images for agent creation:", error);
}
await client.createAgent({
return client.createAgent({
config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -1,967 +0,0 @@
import { describe, expect, it } from "vitest";
import {
resolveAgentForm,
resolveFormState,
resolveThinkingOptionId,
mergeSelectedComposerPreferences,
combineInitialValues,
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
resolveDefaultModel,
INITIAL_USER_MODIFIED,
type AgentFormReducerState,
type UserModifiedFields,
} from "./resolve-agent-form";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const CODEX_MODELS: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.3-codex",
label: "gpt-5.3-codex",
isDefault: true,
defaultThinkingOptionId: "xhigh",
thinkingOptions: [
{ id: "low", label: "low" },
{ id: "xhigh", label: "xhigh", isDefault: true },
],
},
];
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION);
function makeState(
overrides: Partial<AgentFormReducerState["form"]> = {},
modified: Partial<UserModifiedFields> = {},
): AgentFormReducerState {
return {
form: {
serverId: null,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
...overrides,
},
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
};
}
describe("resolveDefaultModel", () => {
it("returns null for empty or null input", () => {
expect(resolveDefaultModel(null)).toBeNull();
expect(resolveDefaultModel([])).toBeNull();
});
it("returns the model marked isDefault", () => {
const models: AgentModelDefinition[] = [
{ provider: "codex", id: "a", label: "A", isDefault: false },
{ provider: "codex", id: "b", label: "B", isDefault: true },
];
expect(resolveDefaultModel(models)?.id).toBe("b");
});
it("falls back to the first model when none is marked default", () => {
const models: AgentModelDefinition[] = [
{ provider: "codex", id: "a", label: "A", isDefault: false },
{ provider: "codex", id: "b", label: "B", isDefault: false },
];
expect(resolveDefaultModel(models)?.id).toBe("a");
});
});
describe("resolveThinkingOptionId", () => {
it("returns empty string when model has no thinking options", () => {
const modelsWithoutThinking: AgentModelDefinition[] = [
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6", isDefault: true },
];
expect(
resolveThinkingOptionId({
availableModels: modelsWithoutThinking,
modelId: "claude-sonnet-4-6",
requestedThinkingOptionId: "",
}),
).toBe("");
});
it("returns the requested option when it is valid", () => {
expect(
resolveThinkingOptionId({
availableModels: CODEX_MODELS,
modelId: "gpt-5.3-codex",
requestedThinkingOptionId: "low",
}),
).toBe("low");
});
it("falls back to defaultThinkingOptionId when requested option is invalid", () => {
expect(
resolveThinkingOptionId({
availableModels: CODEX_MODELS,
modelId: "gpt-5.3-codex",
requestedThinkingOptionId: "invalid",
}),
).toBe("xhigh");
});
it("falls back to first option when no default and requested is invalid", () => {
const modelsNoDefault: AgentModelDefinition[] = [
{
provider: "codex",
id: "m",
label: "M",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
},
];
expect(
resolveThinkingOptionId({
availableModels: modelsNoDefault,
modelId: "m",
requestedThinkingOptionId: "",
}),
).toBe("low");
});
});
describe("combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => {
expect(combineInitialValues(undefined, null)).toBeUndefined();
});
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
const combined = combineInitialValues({}, null);
expect(combined).toEqual({});
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
});
it("injects serverId from options when provided", () => {
expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" });
});
it("keeps other initial values without forcing serverId", () => {
const combined = combineInitialValues({ workingDir: "/repo" }, null);
expect(combined).toEqual({ workingDir: "/repo" });
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
});
it("respects an explicit serverId override (including null) over initialServerId", () => {
expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null });
expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
serverId: "daemon-2",
});
});
});
describe("mergeSelectedComposerPreferences", () => {
it("stores the selected model for the selected provider", () => {
expect(
mergeSelectedComposerPreferences({
preferences: {},
provider: "codex",
updates: { model: "gpt-5.4" },
}),
).toEqual({
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.4" } },
});
});
it("preserves existing provider preferences when the selected model changes", () => {
expect(
mergeSelectedComposerPreferences({
preferences: {
provider: "claude",
providerPreferences: {
codex: {
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
},
provider: "codex",
updates: { model: "gpt-5.4" },
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
});
});
it("stores mode and thinking preferences without dropping the selected model", () => {
expect(
mergeSelectedComposerPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: { "gpt-5.4-mini": "low" },
},
},
},
provider: "codex",
updates: {
mode: "full-access",
thinkingByModel: { "gpt-5.4": "xhigh" },
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "low", "gpt-5.4": "xhigh" },
},
},
});
});
});
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
];
expect(buildProviderDefinitions(entries)).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("resolveFormState", () => {
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
const resolved = resolveFormState(
undefined,
{},
null,
INITIAL_USER_MODIFIED,
makeState().form,
bothProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not auto-select a model on fresh drafts without preferences", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex" },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("falls back to model default when saved thinking preference is invalid", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
const resolved = resolveFormState(
{ model: "default" },
{ provider: "codex" },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
});
it("keeps an explicit initial thinking option when it is valid", () => {
const resolved = resolveFormState(
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
{ provider: "codex" },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
it("falls back to the first thinking option when model exposes options without a provider default", () => {
const claudeWithThinking: AgentModelDefinition[] = [
{
provider: "claude",
id: "default",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
];
const resolved = resolveFormState(
undefined,
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
claudeWithThinking,
INITIAL_USER_MODIFIED,
makeState({ provider: "claude" }).form,
claudeProviderMap,
);
expect(resolved.model).toBe("default");
expect(resolved.thinkingOptionId).toBe("low");
});
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex" },
null,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
claudeProviderMap,
);
expect(resolved.provider).toBeNull();
});
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: true,
model: true,
thinkingOptionId: true,
workingDir: false,
},
makeState({
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
}).form,
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("ignores disabled ready providers when resolving selectable defaults", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const resolved = resolveFormState(
undefined,
{ provider: "claude" },
null,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
selectableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("auto");
});
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]);
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
snapshotEntries: unavailableEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = resolveFormState(
undefined,
{},
null,
{ ...INITIAL_USER_MODIFIED, provider: true },
makeState({
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
}).form,
resolvableProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not force fallback provider when allowed provider map is empty", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex" },
null,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
new Map<AgentProvider, AgentProviderDefinition>(),
);
expect(resolved.provider).toBe("codex");
});
});
describe("resolveAgentForm", () => {
describe("RESOLVE", () => {
it("applies resolved provider and mode when no user modifications", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "RESOLVE",
initialValues: undefined,
preferences: { provider: "codex" },
availableModels: null,
allowedProviderMap: codexProviderMap,
});
expect(next.form.provider).toBe("codex");
expect(next.form.modeId).toBe("auto");
});
it("returns the same state reference when nothing changed", () => {
const state = makeState({ provider: "codex", modeId: "auto" });
const next = resolveAgentForm(state, {
type: "RESOLVE",
initialValues: undefined,
preferences: { provider: "codex" },
availableModels: null,
allowedProviderMap: codexProviderMap,
});
expect(next).toBe(state);
});
it("does not override user-modified provider", () => {
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
const next = resolveAgentForm(state, {
type: "RESOLVE",
initialValues: undefined,
preferences: { provider: "claude" },
availableModels: null,
allowedProviderMap: bothProviderMap,
});
expect(next.form.provider).toBe("codex");
});
});
describe("SET_SERVER_ID", () => {
it("updates serverId without marking it user-modified", () => {
const state = makeState();
const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" });
expect(next.form.serverId).toBe("host-1");
expect(next.userModified.serverId).toBe(false);
});
});
describe("SET_SERVER_ID_FROM_USER", () => {
it("updates serverId and marks it user-modified", () => {
const state = makeState();
const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" });
expect(next.form.serverId).toBe("host-2");
expect(next.userModified.serverId).toBe(true);
});
});
describe("SET_PROVIDER_FROM_USER", () => {
it("switches provider, picks preferred model and mode, marks provider modified", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_FROM_USER",
provider: "codex",
providerModels: CODEX_MODELS,
providerDef: TEST_CODEX_DEFINITION,
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
});
expect(next.form.provider).toBe("codex");
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.modeId).toBe("full-access");
expect(next.userModified.provider).toBe(true);
expect(next.userModified.model).toBe(false);
});
it("falls back to provider defaults when no prefs", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_FROM_USER",
provider: "codex",
providerModels: CODEX_MODELS,
providerDef: TEST_CODEX_DEFINITION,
providerPrefs: undefined,
});
expect(next.form.modeId).toBe("auto");
expect(next.form.model).toBe("gpt-5.3-codex");
});
});
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
it("sets provider, model, and default mode; marks both modified", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider: "codex",
modelId: "gpt-5.3-codex",
providerDef: TEST_CODEX_DEFINITION,
providerModels: CODEX_MODELS,
});
expect(next.form.provider).toBe("codex");
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.modeId).toBe("auto");
expect(next.userModified.provider).toBe(true);
expect(next.userModified.model).toBe(true);
});
it("falls back to provider default model when modelId is empty", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider: "codex",
modelId: "",
providerDef: TEST_CODEX_DEFINITION,
providerModels: CODEX_MODELS,
});
expect(next.form.model).toBe("gpt-5.3-codex");
});
it("selects default thinking option for the chosen model", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider: "codex",
modelId: "gpt-5.3-codex",
providerDef: TEST_CODEX_DEFINITION,
providerModels: CODEX_MODELS,
});
expect(next.form.thinkingOptionId).toBe("xhigh");
});
});
describe("SET_MODE_FROM_USER", () => {
it("updates modeId and marks it modified", () => {
const state = makeState({ provider: "codex", modeId: "auto" });
const next = resolveAgentForm(state, { type: "SET_MODE_FROM_USER", modeId: "full-access" });
expect(next.form.modeId).toBe("full-access");
expect(next.userModified.modeId).toBe(true);
});
});
describe("SET_MODEL_FROM_USER", () => {
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
const next = resolveAgentForm(state, {
type: "SET_MODEL_FROM_USER",
modelId: "gpt-5.3-codex",
availableModels: CODEX_MODELS,
});
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.thinkingOptionId).toBe("xhigh");
expect(next.userModified.model).toBe(true);
});
it("preserves user-chosen thinking option when switching to same model", () => {
const state = makeState(
{ provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
{ thinkingOptionId: true },
);
const next = resolveAgentForm(state, {
type: "SET_MODEL_FROM_USER",
modelId: "gpt-5.3-codex",
availableModels: CODEX_MODELS,
});
expect(next.form.thinkingOptionId).toBe("low");
});
it("falls back to provider default model when modelId is blank", () => {
const state = makeState({ provider: "codex" });
const next = resolveAgentForm(state, {
type: "SET_MODEL_FROM_USER",
modelId: " ",
availableModels: CODEX_MODELS,
});
expect(next.form.model).toBe("gpt-5.3-codex");
});
});
describe("SET_THINKING_OPTION_FROM_USER", () => {
it("updates thinkingOptionId and marks it modified", () => {
const state = makeState({ thinkingOptionId: "xhigh" });
const next = resolveAgentForm(state, {
type: "SET_THINKING_OPTION_FROM_USER",
thinkingOptionId: "low",
});
expect(next.form.thinkingOptionId).toBe("low");
expect(next.userModified.thinkingOptionId).toBe(true);
});
});
describe("SET_WORKING_DIR", () => {
it("updates workingDir without marking it modified", () => {
const state = makeState();
const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" });
expect(next.form.workingDir).toBe("/home/user/proj");
expect(next.userModified.workingDir).toBe(false);
});
});
describe("SET_WORKING_DIR_FROM_USER", () => {
it("updates workingDir and marks it modified", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_WORKING_DIR_FROM_USER",
value: "/home/user/proj",
});
expect(next.form.workingDir).toBe("/home/user/proj");
expect(next.userModified.workingDir).toBe(true);
});
});
describe("AUTO_SELECT_SERVER", () => {
it("sets serverId when currently null", () => {
const state = makeState({ serverId: null });
const next = resolveAgentForm(state, {
type: "AUTO_SELECT_SERVER",
candidateServerId: "host-1",
});
expect(next.form.serverId).toBe("host-1");
});
it("does not override an already-set serverId", () => {
const state = makeState({ serverId: "existing" });
const next = resolveAgentForm(state, {
type: "AUTO_SELECT_SERVER",
candidateServerId: "host-1",
});
expect(next).toBe(state);
});
});
describe("RESET", () => {
it("resets userModified flags while keeping form state", () => {
const state = makeState(
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
{ provider: true, modeId: true, model: true },
);
const next = resolveAgentForm(state, { type: "RESET" });
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
expect(next.form).toEqual(state.form);
});
});
describe("buildProviderDefinitionMap", () => {
it("builds a map from provider id to definition", () => {
const map = buildProviderDefinitionMap([TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION]);
expect(map.get("codex")).toBe(TEST_CODEX_DEFINITION);
expect(map.get("claude")).toBe(TEST_CLAUDE_DEFINITION);
});
});
describe("buildProviderDefinitionMapForStatuses", () => {
it("returns all definitions when no snapshot entries", () => {
const map = buildProviderDefinitionMapForStatuses({
snapshotEntries: undefined,
providerDefinitions: [TEST_CODEX_DEFINITION],
statuses: new Set(["ready"]),
});
expect([...map.keys()]).toEqual(["codex"]);
});
it("filters to only matching-status enabled providers", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
description: "",
defaultModeId: "auto",
modes: [],
},
{
provider: "claude",
status: "loading",
enabled: true,
label: "Claude",
description: "",
defaultModeId: "default",
modes: [],
},
];
const map = buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions: [TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION],
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
expect([...map.keys()]).toEqual(["codex"]);
});
});
});
@@ -1,559 +0,0 @@
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import {
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
export interface FormInitialValues {
serverId?: string | null;
provider?: AgentProvider;
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
workingDir?: string;
}
export interface FormState {
serverId: string | null;
provider: AgentProvider | null;
modeId: string;
model: string;
thinkingOptionId: string;
workingDir: string;
}
export interface UserModifiedFields {
serverId: boolean;
provider: boolean;
modeId: boolean;
model: boolean;
thinkingOptionId: boolean;
workingDir: boolean;
}
export interface AgentFormReducerState {
form: FormState;
userModified: UserModifiedFields;
}
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
};
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
"ready",
"loading",
]);
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
export type AgentFormAction =
| {
type: "RESOLVE";
initialValues: FormInitialValues | undefined;
preferences: FormPreferences | null;
availableModels: AgentModelDefinition[] | null;
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
}
| { type: "SET_SERVER_ID"; value: string | null }
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
| {
type: "SET_PROVIDER_FROM_USER";
provider: AgentProvider;
providerModels: AgentModelDefinition[] | null;
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}
| {
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
provider: AgentProvider;
modelId: string;
providerDef: AgentProviderDefinition | undefined;
providerModels: AgentModelDefinition[] | null;
}
| { type: "SET_MODE_FROM_USER"; modeId: string }
| {
type: "SET_MODEL_FROM_USER";
modelId: string;
availableModels: AgentModelDefinition[] | null;
}
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
| { type: "SET_WORKING_DIR"; value: string }
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
| { type: "RESET" };
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
return typeof modelId === "string" ? modelId.trim() : "";
}
export function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) return null;
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
}
export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
return resolveDefaultModel(availableModels)?.id ?? "";
}
export function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null,
modelId: string,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) return null;
const normalizedModelId = modelId.trim();
if (!normalizedModelId) return null;
return (
availableModels.find((model) => model.id === normalizedModelId) ??
resolveDefaultModel(availableModels)
);
}
export function resolveThinkingOptionId(args: {
availableModels: AgentModelDefinition[] | null;
modelId: string;
requestedThinkingOptionId: string;
}): string {
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) return "";
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
if (
normalizedThinkingOptionId &&
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
) {
return normalizedThinkingOptionId;
}
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
}
export function mergeSelectedComposerPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: args.updates,
});
}
export function combineInitialValues(
initialValues: FormInitialValues | undefined,
initialServerId: string | null,
): FormInitialValues | undefined {
const hasExplicitServerId = initialValues?.serverId !== undefined;
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
return undefined;
}
if (hasExplicitServerId) {
return { ...initialValues, serverId: initialValues?.serverId };
}
if (serverIdFromOptions !== undefined) {
return { ...initialValues, serverId: serverIdFromOptions };
}
return initialValues;
}
export function hasFormStateChanged(prev: FormState, next: FormState): boolean {
return (
prev.serverId !== next.serverId ||
prev.provider !== next.provider ||
prev.modeId !== next.modeId ||
prev.model !== next.model ||
prev.thinkingOptionId !== next.thinkingOptionId ||
prev.workingDir !== next.workingDir
);
}
export function buildProviderDefinitionMap(
providerDefinitions: AgentProviderDefinition[],
): Map<AgentProvider, AgentProviderDefinition> {
return new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition]),
);
}
export function buildProviderDefinitionMapForStatuses(args: {
snapshotEntries: ProviderSnapshotEntry[] | undefined;
providerDefinitions: AgentProviderDefinition[];
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
}): Map<AgentProvider, AgentProviderDefinition> {
if (!args.snapshotEntries?.length) {
return buildProviderDefinitionMap(args.providerDefinitions);
}
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
.map((entry) => entry.provider),
);
return buildProviderDefinitionMap(
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
);
}
function resolveProvider(input: {
currentProvider: AgentProvider | null;
userModified: boolean;
initialValues: FormInitialValues | undefined;
preferences: FormPreferences | null;
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
}): AgentProvider | null {
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
if (userModified) {
if (
currentProvider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(currentProvider)
) {
return null;
}
return currentProvider;
}
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
return initialValues.provider;
}
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
return preferences.provider;
}
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
return null;
}
return currentProvider;
}
function resolveModeId(input: {
provider: AgentProvider | null;
userModified: boolean;
currentModeId: string;
initialValues: FormInitialValues | undefined;
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
input;
if (userModified) return currentModeId;
if (!provider) return "";
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (
typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId)
) {
return initialValues.modeId;
}
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
}
function resolveModelField(input: {
provider: AgentProvider | null;
userModified: boolean;
currentModel: string;
initialValues: FormInitialValues | undefined;
providerPrefs: ProviderPrefs | undefined;
availableModels: AgentModelDefinition[] | null;
}): string {
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
input;
if (userModified) return currentModel;
if (!provider) return "";
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) {
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
}
if (preferredModel) {
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
}
return "";
}
function resolveThinkingOption(input: {
provider: AgentProvider | null;
userModified: boolean;
currentThinkingOptionId: string;
modelId: string;
initialValues: FormInitialValues | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
input;
if (!provider) return "";
if (userModified) return currentThinkingOptionId;
const initialThinkingOptionId =
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const effectiveModelId = modelId.trim();
const preferredThinking = effectiveModelId
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
: "";
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
if (preferredThinking.length > 0) return preferredThinking;
return "";
}
export function resolveFormState(
initialValues: FormInitialValues | undefined,
preferences: FormPreferences | null,
availableModels: AgentModelDefinition[] | null,
userModified: UserModifiedFields,
currentState: FormState,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState {
const result = { ...currentState };
result.provider = resolveProvider({
currentProvider: result.provider,
userModified: userModified.provider,
initialValues,
preferences,
allowedProviderMap,
});
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
: undefined;
result.modeId = resolveModeId({
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues,
providerDef,
providerPrefs,
});
result.model = resolveModelField({
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues,
providerPrefs,
availableModels,
});
result.thinkingOptionId = resolveThinkingOption({
provider: result.provider,
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues,
providerPrefs,
});
if (result.provider && availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: result.model,
requestedThinkingOptionId: result.thinkingOptionId,
});
}
if (!userModified.serverId && initialValues?.serverId !== undefined) {
result.serverId = initialValues.serverId;
}
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir;
}
return result;
}
function pickNextModelForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerModels, providerPrefs } = input;
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
return preferredModel;
}
return defaultModelId;
}
function pickNextModeForProvider(input: {
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerDef, providerPrefs } = input;
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? "";
}
function pickNextThinkingOptionForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
modelId: string;
}): string {
const { providerModels, providerPrefs, modelId } = input;
const preferredThinking = modelId
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
: "";
return resolveThinkingOptionId({
availableModels: providerModels,
modelId,
requestedThinkingOptionId: preferredThinking,
});
}
export function resolveAgentForm(
state: AgentFormReducerState,
action: AgentFormAction,
): AgentFormReducerState {
switch (action.type) {
case "RESOLVE": {
const resolved = resolveFormState(
action.initialValues,
action.preferences,
action.availableModels,
state.userModified,
state.form,
action.allowedProviderMap,
);
if (!hasFormStateChanged(state.form, resolved)) return state;
return { ...state, form: resolved };
}
case "SET_SERVER_ID":
return { ...state, form: { ...state.form, serverId: action.value } };
case "SET_SERVER_ID_FROM_USER":
return {
form: { ...state.form, serverId: action.value },
userModified: { ...state.userModified, serverId: true },
};
case "SET_PROVIDER_FROM_USER": {
const nextModelId = pickNextModelForProvider({
providerModels: action.providerModels,
providerPrefs: action.providerPrefs,
});
const nextModeId = pickNextModeForProvider({
providerDef: action.providerDef,
providerPrefs: action.providerPrefs,
});
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
providerModels: action.providerModels,
providerPrefs: action.providerPrefs,
modelId: nextModelId,
});
return {
form: {
...state.form,
provider: action.provider,
modeId: nextModeId,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
},
userModified: { ...state.userModified, provider: true },
};
}
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
const normalizedModelId = normalizeSelectedModelId(action.modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: action.providerModels,
modelId: nextModelId,
requestedThinkingOptionId: "",
});
return {
form: {
...state.form,
provider: action.provider,
model: nextModelId,
modeId: action.providerDef?.defaultModeId ?? "",
thinkingOptionId: nextThinkingOptionId,
},
userModified: { ...state.userModified, provider: true, model: true },
};
}
case "SET_MODE_FROM_USER":
return {
form: { ...state.form, modeId: action.modeId },
userModified: { ...state.userModified, modeId: true },
};
case "SET_MODEL_FROM_USER": {
const normalizedModelId = normalizeSelectedModelId(action.modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: action.availableModels,
modelId: nextModelId,
requestedThinkingOptionId: state.userModified.thinkingOptionId
? state.form.thinkingOptionId
: "",
});
return {
form: {
...state.form,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
},
userModified: { ...state.userModified, model: true },
};
}
case "SET_THINKING_OPTION_FROM_USER":
return {
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
userModified: { ...state.userModified, thinkingOptionId: true },
};
case "SET_WORKING_DIR":
return { ...state, form: { ...state.form, workingDir: action.value } };
case "SET_WORKING_DIR_FROM_USER":
return {
form: { ...state.form, workingDir: action.value },
userModified: { ...state.userModified, workingDir: true },
};
case "AUTO_SELECT_SERVER":
if (state.form.serverId) return state;
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
case "RESET":
return { ...state, userModified: INITIAL_USER_MODIFIED };
default:
throw new Error("unreachable");
}
}
@@ -0,0 +1,211 @@
import { renderHook, waitFor } from "@testing-library/react";
import { JSDOM } from "jsdom";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { FormPreferences } from "./use-form-preferences";
import { useAgentFormState } from "./use-agent-form-state";
const { mocks } = vi.hoisted(() => ({
mocks: {
preferences: {} as FormPreferences,
isPreferencesLoading: false,
snapshotEntries: [] as ProviderSnapshotEntry[],
updatePreferences: vi.fn(),
refreshSnapshot: vi.fn(),
refetchSnapshotIfStale: vi.fn(),
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "host-1" }],
}));
vi.mock("./use-form-preferences", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-form-preferences")>();
return {
...actual,
useFormPreferences: () => ({
preferences: mocks.preferences,
isLoading: mocks.isPreferencesLoading,
updatePreferences: mocks.updatePreferences,
}),
};
});
vi.mock("./use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: mocks.snapshotEntries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: true,
refresh: mocks.refreshSnapshot,
refetchIfStale: mocks.refetchSnapshotIfStale,
}),
}));
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
writable: true,
});
});
describe("useAgentFormState live preference hydration", () => {
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
mocks.preferences = {};
mocks.isPreferencesLoading = false;
mocks.snapshotEntries = [
{
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{
id: "full-access",
label: "Full Access",
icon: "ShieldAlert",
colorTier: "dangerous",
},
],
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "xhigh", label: "XHigh" },
],
},
],
},
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
models: [
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
isDefault: true,
},
],
},
];
mocks.updatePreferences.mockReset();
mocks.refreshSnapshot.mockReset();
mocks.refetchSnapshotIfStale.mockReset();
});
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
mocks.preferences = {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
},
};
const { result, rerender } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4": "low",
},
},
},
};
rerender();
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
});
it("uses the latest preferences when a separate composer hydrates later", async () => {
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
},
};
const { result } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
expect(result.current.selectedMode).toBe("default");
});
});
});
@@ -0,0 +1,789 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
describe("useAgentFormState", () => {
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds custom provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
enabled: true,
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
provider: "claude",
status: "ready",
enabled: true,
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
},
];
const definitions = buildProviderDefinitions(entries);
expect(definitions).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
id: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Always Ask",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("__private__.combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => {
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
});
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
const combined = __private__.combineInitialValues({}, null);
expect(combined).toEqual({});
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
});
it("injects serverId from options when provided", () => {
expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({
serverId: "daemon-1",
});
});
it("keeps other initial values without forcing serverId", () => {
const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null);
expect(combined).toEqual({ workingDir: "/repo" });
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
});
it("respects an explicit serverId override (including null) over initialServerId", () => {
expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({
serverId: null,
});
expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
serverId: "daemon-2",
});
});
});
describe("__private__.mergeSelectedComposerPreferences", () => {
it("stores the selected model for the selected provider", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
},
},
});
});
it("preserves existing provider preferences when the selected model changes", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "claude",
providerPreferences: {
codex: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
});
});
it("stores mode and thinking preferences without dropping the selected model", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
},
},
provider: "codex",
updates: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "low",
"gpt-5.4": "xhigh",
},
},
},
});
});
});
describe("__private__.resolveFormState", () => {
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.3-codex",
label: "gpt-5.3-codex",
isDefault: true,
defaultThinkingOptionId: "xhigh",
thinkingOptions: [
{ id: "low", label: "low" },
{ id: "xhigh", label: "xhigh", isDefault: true },
],
},
];
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not auto-select a model on fresh drafts without preferences", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("falls back to model default when saved thinking preference is invalid", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
});
it("normalizes legacy model id 'default' to the provider default model", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
});
it("keeps an explicit initial thinking option when it is valid", () => {
const resolved = __private__.resolveFormState(
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
it("falls back to the first thinking option when the model exposes options without a provider default", () => {
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "default",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
];
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
claudeModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "claude",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
claudeProviderMap,
);
expect(resolved.model).toBe("default");
expect(resolved.thinkingOptionId).toBe("low");
});
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
claudeProviderMap,
);
expect(resolved.provider).toBeNull();
});
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: true,
model: true,
thinkingOptionId: true,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("ignores disabled ready providers when resolving selectable defaults", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
selectableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("auto");
});
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: false,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
},
];
const providerDefinitions = buildProviderDefinitions(entries);
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: entries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
});
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
const snapshotProviderStates = [
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
];
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
expect(snapshotProviderStates).toEqual([
{ provider: "codex", enabled: true },
{ provider: "claude", enabled: false },
]);
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
enabled: true,
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: unavailableEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not force fallback provider when allowed provider map is empty", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
new Map<AgentProvider, AgentProviderDefinition>(),
);
expect(resolved.provider).toBe("codex");
});
});
});
+518 -75
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentMode,
@@ -13,26 +13,49 @@ import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
import {
resolveAgentForm,
resolveEffectiveModel,
normalizeSelectedModelId,
resolveDefaultModelId,
mergeSelectedComposerPreferences,
combineInitialValues,
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
INITIAL_USER_MODIFIED,
RESOLVABLE_PROVIDER_STATUSES,
SELECTABLE_PROVIDER_STATUSES,
type FormInitialValues,
type FormState,
} from "./resolve-agent-form";
export type { FormInitialValues } from "./resolve-agent-form";
// Explicit overrides from URL params or "New Agent" button
export interface FormInitialValues {
serverId?: string | null;
provider?: AgentProvider;
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
workingDir?: string;
}
export interface UseAgentFormStateOptions {
// Tracks which fields the user has explicitly modified in this session
interface UserModifiedFields {
serverId: boolean;
provider: boolean;
modeId: boolean;
model: boolean;
thinkingOptionId: boolean;
workingDir: boolean;
}
const INITIAL_USER_MODIFIED: UserModifiedFields = {
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
};
// Internal form state
interface FormState {
serverId: string | null;
provider: AgentProvider | null;
modeId: string;
model: string;
thinkingOptionId: string;
workingDir: string;
}
interface UseAgentFormStateOptions {
initialServerId?: string | null;
initialValues?: FormInitialValues;
isVisible?: boolean;
@@ -74,6 +97,316 @@ export interface UseAgentFormStateResult {
persistFormPreferences: () => Promise<void>;
}
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized) {
return "";
}
return normalized;
}
function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
}
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
}
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
return resolveDefaultModel(availableModels)?.id ?? "";
}
function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null,
modelId: string,
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
}
const normalizedModelId = modelId.trim();
if (!normalizedModelId) {
return null;
}
return (
availableModels.find((model) => model.id === normalizedModelId) ??
resolveDefaultModel(availableModels)
);
}
function resolveThinkingOptionId(args: {
availableModels: AgentModelDefinition[] | null;
modelId: string;
requestedThinkingOptionId: string;
}): string {
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) {
return "";
}
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
if (
normalizedThinkingOptionId &&
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
) {
return normalizedThinkingOptionId;
}
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
}
function mergeSelectedComposerPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: args.updates,
});
}
/**
* Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
*
* Only resolves fields that haven't been user-modified.
*/
function resolveProvider(input: {
currentProvider: AgentProvider | null;
userModified: boolean;
initialValues: FormInitialValues | undefined;
preferences: FormPreferences | null;
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
}): AgentProvider | null {
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
if (userModified) {
if (
currentProvider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(currentProvider)
) {
return null;
}
return currentProvider;
}
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
return initialValues.provider;
}
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
return preferences.provider;
}
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
return null;
}
return currentProvider;
}
function resolveModeId(input: {
provider: AgentProvider | null;
userModified: boolean;
currentModeId: string;
initialValues: FormInitialValues | undefined;
providerDef: AgentProviderDefinition | undefined;
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
}): string {
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
input;
if (userModified) return currentModeId;
if (!provider) return "";
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (
typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId)
) {
return initialValues.modeId;
}
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
}
function resolveModelField(input: {
provider: AgentProvider | null;
userModified: boolean;
currentModel: string;
initialValues: FormInitialValues | undefined;
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
availableModels: AgentModelDefinition[] | null;
}): string {
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
input;
if (userModified) return currentModel;
if (!provider) return "";
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) {
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
}
if (preferredModel) {
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
}
return "";
}
function resolveThinkingOption(input: {
provider: AgentProvider | null;
userModified: boolean;
currentThinkingOptionId: string;
modelId: string;
initialValues: FormInitialValues | undefined;
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
}): string {
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
input;
if (!provider) return "";
if (userModified) return currentThinkingOptionId;
const initialThinkingOptionId =
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const effectiveModelId = modelId.trim();
const preferredThinking = effectiveModelId
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
: "";
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
if (preferredThinking.length > 0) return preferredThinking;
return "";
}
function resolveFormState(
initialValues: FormInitialValues | undefined,
preferences: FormPreferences | null,
availableModels: AgentModelDefinition[] | null,
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState {
const result = { ...currentState };
result.provider = resolveProvider({
currentProvider: result.provider,
userModified: userModified.provider,
initialValues,
preferences,
allowedProviderMap,
});
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
: undefined;
result.modeId = resolveModeId({
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues,
providerDef,
providerPrefs,
});
result.model = resolveModelField({
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues,
providerPrefs,
availableModels,
});
result.thinkingOptionId = resolveThinkingOption({
provider: result.provider,
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues,
providerPrefs,
});
if (result.provider && availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: result.model,
requestedThinkingOptionId: result.thinkingOptionId,
});
}
if (!userModified.serverId && initialValues?.serverId !== undefined) {
result.serverId = initialValues.serverId;
}
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir;
}
return result;
}
function combineInitialValues(
initialValues: FormInitialValues | undefined,
initialServerId: string | null,
): FormInitialValues | undefined {
const hasExplicitServerId = initialValues?.serverId !== undefined;
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
// If nobody provided initial values or an explicit serverId, let preferences drive defaults.
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
return undefined;
}
if (hasExplicitServerId) {
return { ...initialValues, serverId: initialValues?.serverId };
}
if (serverIdFromOptions !== undefined) {
return { ...initialValues, serverId: serverIdFromOptions };
}
return initialValues;
}
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
function buildProviderDefinitionMap(
providerDefinitions: AgentProviderDefinition[],
): Map<AgentProvider, AgentProviderDefinition> {
return new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition]),
);
}
function buildProviderDefinitionMapForStatuses(args: {
snapshotEntries: ProviderSnapshotEntry[] | undefined;
providerDefinitions: AgentProviderDefinition[];
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
}): Map<AgentProvider, AgentProviderDefinition> {
if (!args.snapshotEntries?.length) {
return buildProviderDefinitionMap(args.providerDefinitions);
}
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
.map((entry) => entry.provider),
);
return buildProviderDefinitionMap(
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
);
}
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
function shouldAutoSelectServerId(input: {
isVisible: boolean;
isCreateFlow: boolean;
@@ -101,6 +434,59 @@ function shouldAutoSelectServerId(input: {
return true;
}
function hasFormStateChanged(prev: FormState, next: FormState): boolean {
return (
prev.serverId !== next.serverId ||
prev.provider !== next.provider ||
prev.modeId !== next.modeId ||
prev.model !== next.model ||
prev.thinkingOptionId !== next.thinkingOptionId ||
prev.workingDir !== next.workingDir
);
}
function pickNextModelForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerModels, providerPrefs } = input;
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
return preferredModel;
}
return defaultModelId;
}
function pickNextModeForProvider(input: {
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerDef, providerPrefs } = input;
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? "";
}
function pickNextThinkingOptionForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
modelId: string;
}): string {
const { providerModels, providerPrefs, modelId } = input;
const preferredThinking = modelId
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
: "";
return resolveThinkingOptionId({
availableModels: providerModels,
modelId,
requestedThinkingOptionId: preferredThinking,
});
}
function resolveSelectedProviderModes(input: {
selectedEntry: ProviderSnapshotEntry | null;
provider: AgentProvider | null;
@@ -166,35 +552,34 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const daemons = useHosts();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
const [{ form: formState, userModified }, dispatch] = useReducer(
resolveAgentForm,
initialServerId,
(serverId) => ({
form: {
serverId,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
userModified: INITIAL_USER_MODIFIED,
}),
);
// Track which fields the user has explicitly modified
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
const reducerStateRef = useRef({ form: formState, userModified });
// Form state
const [formState, setFormState] = useState<FormState>(() => ({
serverId: initialServerId,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
}));
const formStateRef = useRef(formState);
useEffect(() => {
reducerStateRef.current = { form: formState, userModified };
}, [formState, userModified]);
formStateRef.current = formState;
}, [formState]);
// Track if we've done initial resolution (to avoid flickering)
const hasResolvedRef = useRef(false);
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
// Reset user modifications when form becomes invisible
useEffect(() => {
if (!isVisible) {
dispatch({ type: "RESET" });
setUserModified(INITIAL_USER_MODIFIED);
hasResolvedRef.current = false;
hydrationPreferencesRef.current = null;
}
@@ -259,11 +644,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
const combinedInitialValues = useMemo(
() => combineInitialValues(initialValues, initialServerId),
[initialValues, initialServerId],
);
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
return combineInitialValues(initialValues, initialServerId);
}, [initialValues, initialServerId]);
// Resolve form state when data sources change
useEffect(() => {
if (!isVisible || !isCreateFlow) {
return;
@@ -278,13 +664,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
dispatch({
type: "RESOLVE",
initialValues: combinedInitialValues,
preferences: hydrationPreferences,
const resolved = resolveFormState(
combinedInitialValues,
hydrationPreferences,
availableModels,
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
});
userModified,
formStateRef.current,
validServerIds,
snapshotResolvableProviderDefinitionMap,
);
if (hasFormStateChanged(formStateRef.current, resolved)) {
setFormState(resolved);
}
hasResolvedRef.current = true;
}, [
@@ -294,9 +686,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
combinedInitialValues,
preferences,
availableModels,
userModified,
validServerIds,
snapshotResolvableProviderDefinitionMap,
]);
// Auto-select the first online host when:
// - no URL override
// - no stored preference applied
// - user hasn't manually picked a host in this session
const onlineServerIdsKey = onlineServerIds.join("|");
useEffect(() => {
const canAutoSelectServerId = shouldAutoSelectServerId({
@@ -306,14 +704,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
hasResolved: hasResolvedRef.current,
userModifiedServerId: userModified.serverId,
initialServerId: combinedInitialValues?.serverId,
currentServerId: reducerStateRef.current.form.serverId,
currentServerId: formStateRef.current.serverId,
});
if (!canAutoSelectServerId) return;
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
if (!candidate) return;
dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate });
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
}, [
combinedInitialValues?.serverId,
isCreateFlow,
@@ -325,8 +723,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
validServerIds,
]);
// User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
setFormState((prev) => ({ ...prev, serverId: value }));
setUserModified((prev) => ({ ...prev, serverId: true }));
}, []);
const setProviderFromUser = useCallback(
@@ -338,14 +738,24 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
dispatch({
type: "SET_PROVIDER_FROM_USER",
provider,
const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs });
const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs });
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
providerModels,
providerDef,
providerPrefs,
modelId: nextModelId,
});
setUserModified((prev) => ({ ...prev, provider: true }));
void updatePreferences({ provider });
setFormState((prev) => ({
...prev,
provider,
modeId: nextModeId,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
},
[
allProviderModels,
@@ -364,14 +774,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
dispatch({
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider,
modelId,
providerDef,
providerModels,
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: "",
});
setFormState((prev) => ({
...prev,
provider,
model: nextModelId,
modeId: providerDef?.defaultModeId ?? "",
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -387,8 +803,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
const provider = reducerStateRef.current.form.provider;
setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true }));
const provider = formStateRef.current.provider;
if (provider) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
@@ -406,11 +823,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const setModelFromUser = useCallback(
(modelId: string) => {
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
const provider = reducerStateRef.current.form.provider;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: nextModelId,
requestedThinkingOptionId: userModified.thinkingOptionId
? formStateRef.current.thinkingOptionId
: "",
});
setFormState((prev) => ({
...prev,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, model: true }));
const provider = formStateRef.current.provider;
if (provider) {
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -422,13 +851,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
}
},
[availableModels, updatePreferences],
[availableModels, updatePreferences, userModified.thinkingOptionId],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
const { provider, model: modelId } = reducerStateRef.current.form;
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
const provider = formStateRef.current.provider;
const modelId = formStateRef.current.model;
if (provider && modelId) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
@@ -447,23 +878,24 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
const setWorkingDir = useCallback((value: string) => {
dispatch({ type: "SET_WORKING_DIR", value });
setFormState((prev) => ({ ...prev, workingDir: value }));
}, []);
const setWorkingDirFromUser = useCallback((value: string) => {
dispatch({ type: "SET_WORKING_DIR_FROM_USER", value });
setFormState((prev) => ({ ...prev, workingDir: value }));
setUserModified((prev) => ({ ...prev, workingDir: true }));
}, []);
const setSelectedServerId = useCallback((value: string | null) => {
dispatch({ type: "SET_SERVER_ID", value });
setFormState((prev) => ({ ...prev, serverId: value }));
}, []);
const refreshProviderModels = useCallback(() => {
void refreshSnapshot();
refreshSnapshot();
}, [refreshSnapshot]);
const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
refetchSnapshotIfStale(formStateRef.current.provider);
}, [refetchSnapshotIfStale]);
const persistFormPreferences = useCallback(async () => {
@@ -561,4 +993,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
}
// Re-export for backwards compatibility
export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = {
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
combineInitialValues,
mergeSelectedComposerPreferences,
resolveDefaultModel,
resolveFormState,
resolveThinkingOptionId,
};
+1 -1
View File
@@ -141,7 +141,7 @@ export function useAgentHistory(options: {
isLoading,
isInitialLoad,
isRevalidating,
hasMore: hasNextPage,
hasMore: Boolean(hasNextPage),
isLoadingMore: isFetchingNextPage,
refreshAll,
loadMore,
+2 -2
View File
@@ -100,7 +100,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
if (!key) {
return false;
}
return readPendingState(input.queryClient)[key] ?? false;
return Boolean(readPendingState(input.queryClient)[key]);
}
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
@@ -420,7 +420,7 @@ export function useArchiveAgent() {
if (!key) {
return false;
}
return (pendingQuery.data ?? {})[key] ?? false;
return Boolean((pendingQuery.data ?? {})[key]);
},
[pendingQuery.data],
);
@@ -0,0 +1,191 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
navigateToWorkspaceMock: vi.fn(),
routerMock: {
back: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
},
pathState: {
pathname: "/h/srv/workspace/ws-2",
},
}));
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("expo-router", () => ({
usePathname: () => pathState.pathname,
useRouter: () => routerMock,
}));
vi.mock("@/constants/layout", () => ({
getIsElectronRuntime: () => true,
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
isWeb: true,
}));
vi.mock("@/utils/shortcut-platform", () => ({
getShortcutOs: () => "mac",
}));
vi.mock("@/hooks/use-active-server-id", () => ({
useActiveServerId: () => "srv",
}));
vi.mock("@/hooks/use-open-project-picker", () => ({
useOpenProjectPicker: () => vi.fn(),
}));
vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
useKeyboardShortcutOverrides: () => ({ overrides: {} }),
}));
vi.mock("@/hooks/use-workspace-navigation", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
}));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
function Probe() {
useKeyboardShortcuts({
enabled: true,
isMobile: false,
toggleAgentList: vi.fn(),
});
return null;
}
describe("useKeyboardShortcuts", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset();
routerMock.push.mockReset();
routerMock.replace.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({
capturingShortcut: false,
commandCenterOpen: false,
sidebarShortcutWorkspaceTargets: [
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
{ serverId: "srv", workspaceId: "ws-3" },
{ serverId: "srv", workspaceId: "ws-4" },
{ serverId: "srv", workspaceId: "ws-5" },
],
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
syncNavigationActiveWorkspace({ current: null });
});
it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: "]",
code: "BracketRight",
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
currentPathname: "/h/srv/workspace/ws-2",
});
expect(event.defaultPrevented).toBe(true);
});
it("dispatches Escape as an agent interrupt action", async () => {
const handleInterrupt = vi.fn(() => true);
const unregister = keyboardActionDispatcher.registerHandler({
handlerId: "test-agent-interrupt",
actions: ["agent.interrupt"],
enabled: true,
priority: 100,
isActive: () => true,
handle: handleInterrupt,
});
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(handleInterrupt).toHaveBeenCalledTimes(1);
unregister();
});
it("returns from desktop settings to the retained workspace without browser history back", async () => {
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
},
});
pathState.pathname = "/settings/general";
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: ",",
code: "Comma",
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
expect(routerMock.back).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});
+249 -62
View File
@@ -3,7 +3,16 @@ import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
} from "@/keyboard/actions";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
type ChordState,
@@ -11,23 +20,26 @@ import {
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import {
routeKeyboardShortcut,
type ShortcutAction,
type ShortcutCallbackName,
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
function hasPayloadKey<K extends string>(
payload: KeyboardShortcutPayload,
key: K,
): payload is KeyboardShortcutPayload & Record<K, never> {
return !!payload && typeof payload === "object" && key in payload;
}
export function useKeyboardShortcuts({
enabled,
isMobile,
@@ -70,57 +82,241 @@ export function useKeyboardShortcuts({
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
const navigateToWorkspaceShortcut = (index: number): boolean => {
const state = useKeyboardShortcutsStore.getState();
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
if (!target) {
return false;
}
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true;
};
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targets = state.sidebarShortcutWorkspaceTargets;
if (targets.length === 0) {
return false;
}
const workspaceRoute =
getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
const target = getRelativeSidebarShortcutTarget({
targets,
currentTarget: workspaceRoute
? {
serverId: workspaceRoute.serverId,
workspaceId: workspaceRoute.workspaceId,
}
: null,
delta,
});
if (!target) {
return false;
}
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true;
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"toggle-focus-mode": toggleFocusMode,
"cycle-theme": cycleTheme,
const openProjectPicker = (): boolean => {
void openProjectPickerAction();
return true;
};
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
switch (action.kind) {
case "none":
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
switch (kind) {
case "focus":
return keyboardActionDispatcher.dispatch({
id: "message-input.focus",
scope: "message-input",
});
case "send":
return keyboardActionDispatcher.dispatch({
id: "message-input.send",
scope: "message-input",
});
case "dictation-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-toggle",
scope: "message-input",
});
case "dictation-cancel":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-cancel",
scope: "message-input",
});
case "dictation-confirm":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-confirm",
scope: "message-input",
});
case "voice-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.voice-toggle",
scope: "message-input",
});
case "voice-mute-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.voice-mute-toggle",
scope: "message-input",
});
default:
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
captureCommandCenterFocusRestore(event);
}
};
const handleDispatchOnlyAction = (action: string): boolean | null => {
switch (action) {
case "agent.interrupt":
return keyboardActionDispatcher.dispatch({ id: "agent.interrupt", scope: "global" });
case "workspace.tab.new":
return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", scope: "workspace" });
case "worktree.archive":
return keyboardActionDispatcher.dispatch({ id: "worktree.archive", scope: "sidebar" });
case "worktree.new":
return keyboardActionDispatcher.dispatch({ id: "worktree.new", scope: "sidebar" });
case "workspace.terminal.new":
return keyboardActionDispatcher.dispatch({
id: "workspace.terminal.new",
scope: "workspace",
});
case "workspace.tab.close.current":
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.close-current",
scope: "workspace",
});
case "sidebar.toggle.right":
return keyboardActionDispatcher.dispatch({
id: "sidebar.toggle.right",
scope: "sidebar",
});
case "workspace.pane.split.right":
case "workspace.pane.split.down":
case "workspace.pane.focus.left":
case "workspace.pane.focus.right":
case "workspace.pane.focus.up":
case "workspace.pane.focus.down":
case "workspace.pane.move-tab.left":
case "workspace.pane.move-tab.right":
case "workspace.pane.move-tab.up":
case "workspace.pane.move-tab.down":
case "workspace.pane.close":
return keyboardActionDispatcher.dispatch({ id: action, scope: "workspace" });
default:
return null;
}
};
const handlePayloadAction = (
action: string,
payload: KeyboardShortcutPayload,
): boolean | null => {
switch (action) {
case "workspace.tab.navigate.index":
if (!hasPayloadKey(payload, "index")) return false;
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.navigate-index",
scope: "workspace",
index: payload.index,
});
case "workspace.tab.navigate.relative":
if (!hasPayloadKey(payload, "delta")) return false;
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.navigate-relative",
scope: "workspace",
delta: payload.delta,
});
case "workspace.navigate.index":
if (!hasPayloadKey(payload, "index")) return false;
return navigateToWorkspaceShortcut(payload.index);
case "workspace.navigate.relative":
if (!hasPayloadKey(payload, "delta")) return false;
return navigateRelativeWorkspace(payload.delta);
case "message-input.action":
if (!hasPayloadKey(payload, "kind")) return false;
return dispatchMessageInputAction(payload.kind);
default:
return null;
}
};
const handleSettingsToggle = (): boolean => {
if (pathname.startsWith("/settings")) {
if (!isMobile) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return true;
}
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
}
router.back();
return true;
}
router.push(buildSettingsRoute());
return true;
};
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
}
store.setCommandCenterOpen(!store.commandCenterOpen);
return true;
};
const handleAction = (input: {
action: string;
payload: KeyboardShortcutPayload;
event: KeyboardEvent;
}): boolean => {
const dispatchOnlyResult = handleDispatchOnlyAction(input.action);
if (dispatchOnlyResult !== null) {
return dispatchOnlyResult;
}
const payloadResult = handlePayloadAction(input.action, input.payload);
if (payloadResult !== null) {
return payloadResult;
}
switch (input.action) {
case "agent.new":
return openProjectPicker();
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "settings.toggle":
return handleSettingsToggle();
case "sidebar.toggle.both":
if (toggleBothSidebars) {
toggleBothSidebars();
}
return true;
case "view.toggle.focus":
if (toggleFocusMode) {
toggleFocusMode();
}
return true;
case "theme.cycle":
if (cycleTheme) {
cycleTheme();
}
return true;
case "command-center.toggle":
return handleCommandCenterToggle(input.event);
case "shortcuts.dialog.toggle": {
const store = useKeyboardShortcutsStore.getState();
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
default:
return false;
}
};
@@ -188,20 +384,11 @@ export function useKeyboardShortcuts({
return;
}
const shortcutAction = routeKeyboardShortcut(
{ action: result.match.action, payload: result.match.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(shortcutAction, event);
const handled = handleAction({
action: result.match.action,
payload: result.match.payload,
event,
});
if (!handled) {
return;
}
@@ -1055,8 +1055,6 @@ function resolvePayload(
return { delta: def.delta };
case "message-input":
return { kind: def.kind };
default:
throw new Error("unreachable");
}
}
@@ -1,357 +0,0 @@
import { describe, expect, it } from "vitest";
import {
routeKeyboardShortcut,
type ShortcutAction,
type ShortcutRoutingContext,
} from "./route-shortcut";
const SIDEBAR_TARGETS = [
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
{ serverId: "srv", workspaceId: "ws-3" },
{ serverId: "srv", workspaceId: "ws-4" },
{ serverId: "srv", workspaceId: "ws-5" },
] as const;
function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRoutingContext {
return {
pathname: "/h/srv/workspace/ws-2",
isMobile: false,
sidebarShortcutTargets: SIDEBAR_TARGETS,
navigationActiveWorkspace: null,
lastNavigationWorkspaceRoute: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
...overrides,
};
}
describe("routeKeyboardShortcut — dispatch passthroughs", () => {
it.each([
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
["sidebar.toggle.right", { id: "sidebar.toggle.right", scope: "sidebar" }],
["workspace.pane.split.right", { id: "workspace.pane.split.right", scope: "workspace" }],
["workspace.pane.split.down", { id: "workspace.pane.split.down", scope: "workspace" }],
["workspace.pane.focus.left", { id: "workspace.pane.focus.left", scope: "workspace" }],
["workspace.pane.focus.right", { id: "workspace.pane.focus.right", scope: "workspace" }],
["workspace.pane.focus.up", { id: "workspace.pane.focus.up", scope: "workspace" }],
["workspace.pane.focus.down", { id: "workspace.pane.focus.down", scope: "workspace" }],
["workspace.pane.move-tab.left", { id: "workspace.pane.move-tab.left", scope: "workspace" }],
["workspace.pane.move-tab.right", { id: "workspace.pane.move-tab.right", scope: "workspace" }],
["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }],
["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }],
])("%s → dispatch %j", (action, expected) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
kind: "dispatch",
action: expected,
});
});
});
describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
it("forwards index payloads to the workspace.tab.navigate-index dispatch", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.tab.navigate.index", payload: { index: 3 } },
makeCtx(),
),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id: "workspace.tab.navigate-index", scope: "workspace", index: 3 },
});
});
it("returns none when index payload is missing", () => {
expect(
routeKeyboardShortcut({ action: "workspace.tab.navigate.index", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("forwards delta payloads to the workspace.tab.navigate-relative dispatch", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.tab.navigate.relative", payload: { delta: -1 } },
makeCtx(),
),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
});
});
it("returns none when delta payload is missing", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.tab.navigate.relative", payload: null },
makeCtx(),
),
).toEqual<ShortcutAction>({ kind: "none" });
});
});
describe("routeKeyboardShortcut — workspace.navigate.index", () => {
it("navigates to the sidebar target at index-1", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.index", payload: { index: 4 } },
makeCtx(),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "ws-4",
});
});
it("returns none when the target index is out of range", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.index", payload: { index: 99 } },
makeCtx(),
),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("returns none when the index payload is missing", () => {
expect(
routeKeyboardShortcut({ action: "workspace.navigate.index", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("returns none when there are no sidebar targets", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.index", payload: { index: 1 } },
makeCtx({ sidebarShortcutTargets: [] }),
),
).toEqual<ShortcutAction>({ kind: "none" });
});
});
describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
it("uses the retained navigation workspace selection over a stale pathname", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
makeCtx({
pathname: "/h/srv/workspace/ws-2",
navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-4" },
}),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "ws-5",
});
});
it("falls back to the pathname workspace when no retained selection exists", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: -1 } },
makeCtx({ pathname: "/h/srv/workspace/ws-3", navigationActiveWorkspace: null }),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "ws-2",
});
});
it("wraps from the last target forward to the first", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
makeCtx({ navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-5" } }),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "ws-1",
});
});
it("returns none when there are no sidebar targets", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
makeCtx({ sidebarShortcutTargets: [] }),
),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("returns none when the delta payload is missing", () => {
expect(
routeKeyboardShortcut({ action: "workspace.navigate.relative", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("falls back to the first target when the current workspace is not in the sidebar", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
makeCtx({
pathname: "/settings/general",
navigationActiveWorkspace: { serverId: "other", workspaceId: "ws-x" },
}),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "ws-1",
});
});
});
describe("routeKeyboardShortcut — message-input.action", () => {
it.each([
["focus", "message-input.focus"],
["send", "message-input.send"],
["dictation-toggle", "message-input.dictation-toggle"],
["dictation-cancel", "message-input.dictation-cancel"],
["dictation-confirm", "message-input.dictation-confirm"],
["voice-toggle", "message-input.voice-toggle"],
["voice-mute-toggle", "message-input.voice-mute-toggle"],
] as const)("kind=%s → dispatch %s", (kind, id) => {
expect(
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
).toEqual<ShortcutAction>({
kind: "dispatch",
action: { id, scope: "message-input" },
});
});
it("returns none for unsupported message-input kinds (queue)", () => {
expect(
routeKeyboardShortcut(
{ action: "message-input.action", payload: { kind: "queue" } },
makeCtx(),
),
).toEqual<ShortcutAction>({ kind: "none" });
});
it("returns none when kind is missing", () => {
expect(
routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "none" });
});
});
describe("routeKeyboardShortcut — settings.toggle", () => {
it("pushes to the settings root when not currently in settings", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({ pathname: "/h/srv/workspace/ws-2" }),
),
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
});
it("replaces to the retained workspace route when leaving settings on desktop", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: false,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({
kind: "router-replace",
route: "/h/srv/workspace/ws-2",
});
});
it("falls back to router.back() when no retained workspace exists", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
});
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: true,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
});
});
describe("routeKeyboardShortcut — callbacks and pickers", () => {
it.each([
["sidebar.toggle.left", "toggle-agent-list"],
["sidebar.toggle.both", "toggle-both-sidebars"],
["view.toggle.focus", "toggle-focus-mode"],
["theme.cycle", "cycle-theme"],
] as const)("%s → callback %s", (action, name) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({
kind: "callback",
name,
});
});
it("agent.new → open-project-picker", () => {
expect(
routeKeyboardShortcut({ action: "agent.new", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "open-project-picker" });
});
});
describe("routeKeyboardShortcut — toggle dialogs", () => {
it("opens the command center when closed", () => {
expect(
routeKeyboardShortcut(
{ action: "command-center.toggle", payload: null },
makeCtx({ commandCenterOpen: false }),
),
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true });
});
it("closes the command center when open", () => {
expect(
routeKeyboardShortcut(
{ action: "command-center.toggle", payload: null },
makeCtx({ commandCenterOpen: true }),
),
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: false });
});
it("toggles the shortcuts dialog", () => {
expect(
routeKeyboardShortcut(
{ action: "shortcuts.dialog.toggle", payload: null },
makeCtx({ shortcutsDialogOpen: false }),
),
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: true });
expect(
routeKeyboardShortcut(
{ action: "shortcuts.dialog.toggle", payload: null },
makeCtx({ shortcutsDialogOpen: true }),
),
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: false });
});
});
describe("routeKeyboardShortcut — unknown actions", () => {
it("returns none for unknown action ids", () => {
expect(
routeKeyboardShortcut({ action: "totally.made.up", payload: null }, makeCtx()),
).toEqual<ShortcutAction>({ kind: "none" });
});
});
-217
View File
@@ -1,217 +0,0 @@
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import {
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
getRelativeSidebarShortcutTarget,
type SidebarShortcutWorkspaceTarget,
} from "@/utils/sidebar-shortcuts";
export interface ShortcutRoutingContext {
pathname: string;
isMobile: boolean;
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
commandCenterOpen: boolean;
shortcutsDialogOpen: boolean;
}
export interface ShortcutRoutingInput {
action: string;
payload: KeyboardShortcutPayload;
}
export type ShortcutCallbackName =
| "toggle-agent-list"
| "toggle-both-sidebars"
| "toggle-focus-mode"
| "cycle-theme";
export type ShortcutAction =
| { kind: "none" }
| { kind: "dispatch"; action: KeyboardActionDefinition }
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
| { kind: "router-replace"; route: string }
| { kind: "router-back" }
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
// Action ids whose routing is a no-payload pass-through to the dispatcher.
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
"sidebar.toggle.right": { id: "sidebar.toggle.right", scope: "sidebar" },
"workspace.pane.split.right": { id: "workspace.pane.split.right", scope: "workspace" },
"workspace.pane.split.down": { id: "workspace.pane.split.down", scope: "workspace" },
"workspace.pane.focus.left": { id: "workspace.pane.focus.left", scope: "workspace" },
"workspace.pane.focus.right": { id: "workspace.pane.focus.right", scope: "workspace" },
"workspace.pane.focus.up": { id: "workspace.pane.focus.up", scope: "workspace" },
"workspace.pane.focus.down": { id: "workspace.pane.focus.down", scope: "workspace" },
"workspace.pane.move-tab.left": { id: "workspace.pane.move-tab.left", scope: "workspace" },
"workspace.pane.move-tab.right": { id: "workspace.pane.move-tab.right", scope: "workspace" },
"workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
"workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" },
"workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" },
};
const SIMPLE_CALLBACKS: Record<string, ShortcutCallbackName> = {
"sidebar.toggle.left": "toggle-agent-list",
"sidebar.toggle.both": "toggle-both-sidebars",
"view.toggle.focus": "toggle-focus-mode",
"theme.cycle": "cycle-theme",
};
const MESSAGE_INPUT_DISPATCH: Record<
MessageInputKeyboardActionKind,
KeyboardActionDefinition | null
> = {
focus: { id: "message-input.focus", scope: "message-input" },
send: { id: "message-input.send", scope: "message-input" },
"dictation-toggle": { id: "message-input.dictation-toggle", scope: "message-input" },
"dictation-cancel": { id: "message-input.dictation-cancel", scope: "message-input" },
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
queue: null,
};
function hasPayloadKey<K extends "index" | "delta" | "kind">(
payload: KeyboardShortcutPayload,
key: K,
): payload is Extract<KeyboardShortcutPayload, Record<K, unknown>> {
return !!payload && typeof payload === "object" && key in payload;
}
function dispatch(action: KeyboardActionDefinition): ShortcutAction {
return { kind: "dispatch", action };
}
function routeWorkspaceTabNavigateIndex(payload: KeyboardShortcutPayload): ShortcutAction {
if (!hasPayloadKey(payload, "index")) return NONE;
return dispatch({
id: "workspace.tab.navigate-index",
scope: "workspace",
index: payload.index,
});
}
function routeWorkspaceTabNavigateRelative(payload: KeyboardShortcutPayload): ShortcutAction {
if (!hasPayloadKey(payload, "delta")) return NONE;
return dispatch({
id: "workspace.tab.navigate-relative",
scope: "workspace",
delta: payload.delta,
});
}
function routeWorkspaceNavigateIndex(
payload: KeyboardShortcutPayload,
ctx: ShortcutRoutingContext,
): ShortcutAction {
if (!hasPayloadKey(payload, "index")) return NONE;
const target = ctx.sidebarShortcutTargets[payload.index - 1] ?? null;
if (!target) return NONE;
return {
kind: "navigate-workspace",
serverId: target.serverId,
workspaceId: target.workspaceId,
};
}
function routeWorkspaceNavigateRelative(
payload: KeyboardShortcutPayload,
ctx: ShortcutRoutingContext,
): ShortcutAction {
if (!hasPayloadKey(payload, "delta")) return NONE;
if (ctx.sidebarShortcutTargets.length === 0) return NONE;
const currentWorkspace =
ctx.navigationActiveWorkspace ?? parseHostWorkspaceRouteFromPathname(ctx.pathname);
const target = getRelativeSidebarShortcutTarget({
targets: ctx.sidebarShortcutTargets,
currentTarget: currentWorkspace
? { serverId: currentWorkspace.serverId, workspaceId: currentWorkspace.workspaceId }
: null,
delta: payload.delta,
});
if (!target) return NONE;
return {
kind: "navigate-workspace",
serverId: target.serverId,
workspaceId: target.workspaceId,
};
}
function routeMessageInputAction(payload: KeyboardShortcutPayload): ShortcutAction {
if (!hasPayloadKey(payload, "kind")) return NONE;
const action = MESSAGE_INPUT_DISPATCH[payload.kind];
if (!action) return NONE;
return dispatch(action);
}
function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
if (!ctx.pathname.startsWith("/settings")) {
return { kind: "router-push", route: buildSettingsRoute() };
}
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
return {
kind: "router-replace",
route: buildHostWorkspaceRoute(
ctx.lastNavigationWorkspaceRoute.serverId,
ctx.lastNavigationWorkspaceRoute.workspaceId,
),
};
}
return { kind: "router-back" };
}
export function routeKeyboardShortcut(
input: ShortcutRoutingInput,
ctx: ShortcutRoutingContext,
): ShortcutAction {
const passthrough = PASSTHROUGH_DISPATCH[input.action];
if (passthrough) {
return dispatch(passthrough);
}
const callback = SIMPLE_CALLBACKS[input.action];
if (callback) {
return { kind: "callback", name: callback };
}
switch (input.action) {
case "workspace.tab.navigate.index":
return routeWorkspaceTabNavigateIndex(input.payload);
case "workspace.tab.navigate.relative":
return routeWorkspaceTabNavigateRelative(input.payload);
case "workspace.navigate.index":
return routeWorkspaceNavigateIndex(input.payload, ctx);
case "workspace.navigate.relative":
return routeWorkspaceNavigateRelative(input.payload, ctx);
case "message-input.action":
return routeMessageInputAction(input.payload);
case "agent.new":
return { kind: "open-project-picker" };
case "settings.toggle":
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
default:
return NONE;
}
}
@@ -0,0 +1,570 @@
/**
* @vitest-environment jsdom
*/
import React, { useImperativeHandle } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "@server/client/daemon-client";
import { PaneFocusProvider, PaneProvider, type PaneFocusContextValue } from "@/panels/pane-context";
import { agentPanelRegistration } from "@/panels/agent-panel";
import { useDraftStore } from "@/stores/draft-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
interface PanelTestTheme {
colors: {
foreground: string;
foregroundMuted: string;
surface0: string;
surface1: string;
surface2: string;
surface3: string;
border: string;
destructive: string;
};
spacing: Record<number, number>;
borderRadius: Record<string, number>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
iconSize: Record<string, number>;
}
type PanelTestStyles = Record<string, unknown>;
type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles;
const {
composerRenderCount,
composerUnmountCount,
latestComposerCwd,
latestComposerIsPaneFocused,
streamRenderCount,
latestStreamPermissionKeys,
latestStreamText,
runtimeIsConnected,
theme,
runtimeClient,
} = vi.hoisted(() => {
Object.defineProperty(globalThis, "__DEV__", {
value: false,
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
return {
composerRenderCount: vi.fn(),
composerUnmountCount: vi.fn(),
latestComposerCwd: { current: null as string | null },
latestComposerIsPaneFocused: { current: null as boolean | null },
streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null },
runtimeIsConnected: { current: false },
theme: {
colors: {
foreground: "#ffffff",
foregroundMuted: "#999999",
surface0: "#000000",
surface1: "#111111",
surface2: "#222222",
surface3: "#333333",
border: "#444444",
destructive: "#ff0000",
},
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6, lg: 8, xl: 12 },
fontSize: { sm: 13, base: 15, lg: 18 },
fontWeight: { medium: "500" },
iconSize: { lg: 22 },
} satisfies PanelTestTheme,
runtimeClient: {
fetchAgent: vi.fn(),
fetchAgentTimeline: vi.fn(),
},
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
}));
vi.mock("react-native-unistyles", () => {
return {
StyleSheet: {
create: createPanelTestStyles,
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }),
};
});
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles {
return typeof factory === "function" ? factory(theme) : factory;
}
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
useHostRuntimeLastError: () => null,
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: vi.fn(async () => {}),
persistAttachmentFromDataUrl: vi.fn(),
persistAttachmentFromFileUri: vi.fn(),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => null,
}));
vi.mock("@/components/file-drop-zone", () => ({
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/toast-host", () => ({
ToastViewport: () => null,
useToastHost: () => ({
toast: null,
dismiss: vi.fn(),
api: {
show: vi.fn(),
},
}),
}));
vi.mock("@/components/archived-agent-callout", () => ({
ArchivedAgentCallout: ({ agentId }: { agentId: string }) => (
<div data-testid="archived-agent-callout">{agentId}</div>
),
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: null }),
}));
vi.mock("@/hooks/use-archive-agent", () => ({
useArchiveAgent: () => ({
isArchivingAgent: () => false,
}),
}));
vi.mock("@/components/composer", () => ({
Composer: ({ cwd, isPaneFocused }: { cwd: string; isPaneFocused: boolean }) => {
React.useEffect(
() => () => {
composerUnmountCount();
},
[],
);
composerRenderCount();
latestComposerCwd.current = cwd;
latestComposerIsPaneFocused.current = isPaneFocused;
return <div data-testid="composer">{cwd}</div>;
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: React.memo(
React.forwardRef<
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
{ pendingPermissions: Map<string, PendingPermission>; streamItems: StreamItem[] }
>(function AgentStreamView({ pendingPermissions, streamItems }, ref) {
useImperativeHandle(ref, () => ({
prepareForViewportChange: vi.fn(),
scrollToBottom: vi.fn(),
}));
streamRenderCount();
latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys());
latestStreamText.current =
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
}),
),
}));
function makeClient(): DaemonClient {
return new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "panel-render-isolation-test",
});
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date("2026-04-20T00:00:00.000Z");
return {
serverId: "server",
id: "agent",
provider: "codex",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
lastError: null,
title: "Render isolation",
cwd: "/workspace/one",
model: null,
labels: {},
...overrides,
};
}
function makeFetchedAgentResult(agent: Agent): Awaited<ReturnType<DaemonClient["fetchAgent"]>> {
return {
project: {
projectKey: agent.cwd,
projectName: "workspace",
checkout: {
cwd: agent.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
agent: {
id: agent.id,
provider: agent.provider,
status: agent.status,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
runtimeInfo: agent.runtimeInfo ?? {
provider: agent.provider,
sessionId: null,
},
capabilities: agent.capabilities,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
pendingPermissions: agent.pendingPermissions,
persistence: agent.persistence,
title: agent.title,
cwd: agent.cwd,
model: agent.model,
thinkingOptionId: agent.thinkingOptionId ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
archivedAt: agent.archivedAt?.toISOString() ?? null,
labels: agent.labels,
lastError: agent.lastError ?? undefined,
},
};
}
function seedReadyAgent(agent: Agent = makeAgent()) {
const store = useSessionStore.getState();
store.initializeSession("server", makeClient());
store.setAgents("server", new Map([["agent", agent]]));
store.setAgentAuthoritativeHistoryApplied("server", "agent", true);
}
function buildTestPaneValue() {
return {
serverId: "server",
workspaceId: "workspace",
tabId: "agent-agent",
target: { kind: "agent" as const, agentId: "agent" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
};
}
async function renderAgentPanel(
root: Root,
focus: PaneFocusContextValue = {
isWorkspaceFocused: true,
isPaneFocused: false,
isInteractive: false,
focusPane: vi.fn(),
},
) {
const AgentPanel = agentPanelRegistration.component;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const paneValue = buildTestPaneValue();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PaneProvider value={paneValue}>
<PaneFocusProvider value={focus}>
<AgentPanel />
</PaneFocusProvider>
</PaneProvider>
</QueryClientProvider>,
);
await Promise.resolve();
});
}
function updateCurrentAgentStream(text: string) {
act(() => {
useSessionStore.getState().setAgentStreamTail("server", (previous) => {
const next = new Map(previous);
next.set("agent", [
{
kind: "user_message",
id: `message-${text}`,
text,
timestamp: new Date("2026-04-20T00:00:01.000Z"),
},
]);
return next;
});
});
}
function makePendingPermission(agentId: string, requestId: string): PendingPermission {
const request: AgentPermissionRequest = {
id: requestId,
provider: "codex",
name: `permission-${requestId}`,
kind: "tool",
};
const key = `${agentId}:${requestId}`;
return {
key,
agentId,
request,
};
}
function updatePendingPermissions(permissions: PendingPermission[]) {
act(() => {
useSessionStore
.getState()
.setPendingPermissions(
"server",
new Map(permissions.map((permission) => [permission.key, permission])),
);
});
}
async function updateCurrentAgentCwd(cwd: string) {
await act(async () => {
useSessionStore.getState().setAgents("server", (previous) => {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, cwd });
return next;
});
await Promise.resolve();
});
}
function archiveSeededAgent(previous: Map<string, Agent>): Map<string, Agent> {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") });
return next;
}
describe("AgentPanel render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
useDraftStore.setState({ drafts: {}, createModalDraft: null });
composerRenderCount.mockClear();
composerUnmountCount.mockClear();
latestComposerCwd.current = null;
latestComposerIsPaneFocused.current = null;
streamRenderCount.mockClear();
latestStreamPermissionKeys.current = [];
latestStreamText.current = null;
runtimeIsConnected.current = false;
runtimeClient.fetchAgent.mockReset();
runtimeClient.fetchAgentTimeline.mockReset();
});
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const composerBaseline = composerRenderCount.mock.calls.length;
const streamBaseline = streamRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
expect(streamBaseline).toBeGreaterThan(0);
updateCurrentAgentStream("stream-only update");
expect(latestStreamText.current).toBe("stream-only update");
expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1);
expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline);
});
it("does not advertise the composer as focused when its workspace is hidden", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root, {
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
focusPane: vi.fn(),
});
expect(latestComposerIsPaneFocused.current).toBe(false);
});
it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
expect(latestComposerCwd.current).toBe("/workspace/one");
const composerBaseline = composerRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
await updateCurrentAgentCwd("/workspace/two");
expect(latestComposerCwd.current).toBe("/workspace/two");
expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline);
await act(async () => {
useSessionStore.getState().setAgents("server", archiveSeededAgent);
await Promise.resolve();
});
expect(composerUnmountCount).toHaveBeenCalledTimes(1);
});
it("keeps stream permissions stable for unrelated pending permission updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const initialRenderCount = streamRenderCount.mock.calls.length;
expect(initialRenderCount).toBeGreaterThan(0);
expect(latestStreamPermissionKeys.current).toEqual([]);
const unrelatedPermission = makePendingPermission("other-agent", "unrelated");
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount);
const agentPermission = makePendingPermission("agent", "current-agent");
updatePendingPermissions([unrelatedPermission, agentPermission]);
expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1);
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
});
it("renders an archived lazy detail without adding it to the active agent store", async () => {
const archivedAgent = makeAgent({
archivedAt: new Date("2026-04-20T00:00:02.000Z"),
});
runtimeIsConnected.current = true;
runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent));
runtimeClient.fetchAgentTimeline.mockResolvedValue({
agent: null,
events: [],
nextCursor: null,
hasMore: false,
});
useSessionStore
.getState()
.initializeSession("server", runtimeClient as unknown as DaemonClient);
useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root, {
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
focusPane: vi.fn(),
});
const timeoutAt = Date.now() + 300;
while (
!container?.querySelector('[data-testid="archived-agent-callout"]') &&
Date.now() < timeoutAt
) {
await act(async () => {
await Promise.resolve();
});
}
expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull();
expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false);
expect(
useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt,
).toEqual(archivedAgent.archivedAt);
});
});
+5 -5
View File
@@ -7,17 +7,17 @@ export interface PaneContextValue {
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
openFileInWorkspace: (filePath: string) => void;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
focusPane: () => void;
focusPane(): void;
}
const PaneContext = createContext<PaneContextValue | null>(null);
@@ -1,96 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
kind: "pr",
number,
title,
url: `https://example.com/pull/${number}`,
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName,
};
}
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
return { kind: "github_pr", item };
}
function issueAttachment(number: number): UserComposerAttachment {
return {
kind: "github_issue",
item: {
kind: "issue",
number,
title: `Issue ${number}`,
url: `https://example.com/issues/${number}`,
state: "open",
body: null,
labels: [],
},
};
}
describe("syncPickerPrAttachment", () => {
it("selects a PR when no previous picker PR is set", () => {
const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({
attachments: [],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr },
});
expect(result.attachedPrNumber).toBe(202);
expect(result.attachments).toEqual([prAttachment(pr)]);
});
it("selects a branch without modifying attachments when no previous picker PR", () => {
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue],
previousPickerPrNumber: null,
item: { kind: "branch", name: "dev" },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toEqual([issue]);
});
it("replaces the previous picker PR when a different PR is selected", () => {
const prA = makePrItem(202, "Refactor picker", "feature/picker");
const prB = makePrItem(303, "Polish chip", "feature/chip");
const result = syncPickerPrAttachment({
attachments: [prAttachment(prA)],
previousPickerPrNumber: 202,
item: { kind: "github-pr", item: prB },
});
expect(result.attachedPrNumber).toBe(303);
expect(result.attachments).toEqual([prAttachment(prB)]);
});
it("removes the previous picker PR and adds no new attachment when a branch is selected", () => {
const pr = makePrItem(202, "Refactor picker");
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue, prAttachment(pr)],
previousPickerPrNumber: 202,
item: { kind: "branch", name: "dev" },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toEqual([issue]);
});
it("does not duplicate a PR that was already manually attached by the user", () => {
const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({
attachments: [prAttachment(pr)],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toHaveLength(1);
});
});
@@ -1,35 +0,0 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { PickerItem } from "./new-workspace-picker-item";
// The picker "owns" at most one PR attachment at a time. When the user selects
// a different item the previously-owned PR is removed before the new one is added.
// User-added attachments for other PRs/issues are left untouched.
export function syncPickerPrAttachment(input: {
attachments: UserComposerAttachment[];
previousPickerPrNumber: number | null;
item: PickerItem;
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
let nextAttachments = input.attachments;
let attachedPrNumber: number | null = null;
if (input.previousPickerPrNumber !== null) {
nextAttachments = nextAttachments.filter(
(attachment) =>
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
);
}
if (input.item.kind === "github-pr") {
const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some(
(attachment) =>
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
);
if (!hasExistingPrAttachment) {
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
attachedPrNumber = selectedPr.number;
}
}
return { attachments: nextAttachments, attachedPrNumber };
}
@@ -0,0 +1,982 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { GitHubSearchItem } from "@server/shared/messages";
import { NewWorkspaceScreen } from "./new-workspace-screen";
const {
theme,
mockClient,
mergeWorkspacesMock,
navigateMock,
saveDraftInputMock,
clearDraftInputMock,
queueDraftSubmissionMock,
createdAgent: _createdAgent,
createdWorkspace,
prItem,
prItemB,
issueItem,
initialAttachments,
initialDraftState,
} = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { medium: "500" },
shadow: { md: {} },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
border: "#555",
destructive: "#ff453a",
palette: {
zinc: { 600: "#52525b" },
},
},
};
const hoistedPrItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor picker",
url: "https://example.com/pull/202",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker",
};
const hoistedPrItemB: GitHubSearchItem = {
kind: "pr",
number: 303,
title: "Polish composer chip",
url: "https://example.com/pull/303",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/composer-chip",
};
const hoistedIssueItem: GitHubSearchItem = {
kind: "issue",
number: 44,
title: "Keep manual attachment",
url: "https://example.com/issues/44",
state: "open",
body: null,
labels: [],
};
const hoistedInitialAttachments: ComposerAttachment[] = [];
const hoistedInitialDraftState = { text: "" };
const hoistedCreatedWorkspace = {
id: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
};
const hoistedCreatedAgent = {
id: "agent-1",
cwd: hoistedCreatedWorkspace.workspaceDirectory,
};
const hoistedMockClient = {
isConnected: true,
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
searchGitHub: vi.fn(async () => ({
items: [hoistedPrItem, hoistedPrItemB],
githubFeaturesEnabled: true,
error: null,
})),
createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({
workspace: hoistedCreatedWorkspace,
error: null,
})),
createAgent: vi.fn(async () => hoistedCreatedAgent),
};
return {
theme: hoistedTheme,
mockClient: hoistedMockClient,
mergeWorkspacesMock: vi.fn(),
navigateMock: vi.fn(),
saveDraftInputMock: vi.fn(),
clearDraftInputMock: vi.fn(),
queueDraftSubmissionMock: vi.fn(),
createdAgent: hoistedCreatedAgent,
createdWorkspace: hoistedCreatedWorkspace,
prItem: hoistedPrItem,
prItemB: hoistedPrItemB,
issueItem: hoistedIssueItem,
initialAttachments: hoistedInitialAttachments,
initialDraftState: hoistedInitialDraftState,
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
HEADER_INNER_HEIGHT: 48,
HEADER_INNER_HEIGHT_MOBILE: 56,
HEADER_TOP_PADDING_MOBILE: 8,
MAX_CONTENT_WIDTH: 900,
useIsCompactFormFactor: () => false,
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
GitBranch: createIcon("GitBranch"),
GitPullRequest: createIcon("GitPullRequest"),
};
});
function flattenReanimatedStyle(style: unknown) {
if (!Array.isArray(style)) {
return style;
}
return Object.assign({}, ...style.filter(Boolean));
}
vi.mock("react-native-reanimated", () => ({
default: {
View: ({
testID,
style,
...props
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
const flattenedStyle = flattenReanimatedStyle(style);
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
},
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
}),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
}));
vi.mock("@/utils/agent-snapshots", () => ({
normalizeAgentSnapshot: (agent: unknown) => agent,
}));
vi.mock("@/utils/encode-images", () => ({
encodeImages: vi.fn(async () => []),
}));
vi.mock("@/utils/error-messages", () => ({
toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}));
vi.mock("@/utils/workspace-execution", () => ({
requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({
workspaceDirectory: input.workspace.workspaceDirectory,
}),
}));
vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateMock,
}));
vi.mock("@/stores/draft-keys", () => ({
buildDraftStoreKey: ({
serverId,
agentId,
draftId,
}: {
serverId: string;
agentId: string;
draftId?: string | null;
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
generateDraftId: () => "draft-new-workspace",
}));
vi.mock("@/stores/draft-store", () => ({
useDraftStore: {
getState: () => ({
saveDraftInput: saveDraftInputMock,
clearDraftInput: clearDraftInputMock,
}),
},
}));
vi.mock("@/stores/workspace-draft-submission-store", () => ({
useWorkspaceDraftSubmissionStore: {
getState: () => ({
setPending: queueDraftSubmissionMock,
}),
},
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/hooks/use-agent-input-draft", () => ({
useAgentInputDraft: () => {
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>(initialAttachments);
const [text, setText] = React.useState(initialDraftState.text);
return {
text,
setText,
attachments,
setAttachments,
cwd: "/repo",
composerState: {
selectedProvider: "claude-code",
selectedMode: "",
modeOptions: [],
effectiveModelId: null,
effectiveThinkingOptionId: null,
commandDraftConfig: undefined,
statusControls: {},
},
};
},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
}));
interface ComposerMockProps {
onSubmitMessage: (payload: {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}) => void;
submitBehavior?: "clear" | "preserve-and-lock";
submitIcon?: "arrow" | "return";
isSubmitLoading?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}
interface AttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachments: ComposerAttachment[];
isDisabled: boolean;
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}
function ComposerMockAttachmentPill({
attachment,
attachments,
isDisabled,
onChangeAttachments,
}: AttachmentPillProps) {
const handleRemove = React.useCallback(() => {
onChangeAttachments(
attachments.filter(
(candidate) =>
candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number,
),
);
}, [attachment, attachments, onChangeAttachments]);
return (
<div data-testid="composer-github-attachment-pill">
#{attachment.item.number} {attachment.item.title}
<button
type="button"
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${attachment.item.number}`}
disabled={isDisabled}
onClick={handleRemove}
>
Remove
</button>
</div>
);
}
function ComposerMock({
onSubmitMessage,
submitBehavior,
submitIcon,
isSubmitLoading,
value,
onChangeText,
attachments,
onChangeAttachments,
}: ComposerMockProps) {
const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading);
const handleTextareaChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => onChangeText(event.currentTarget.value),
[onChangeText],
);
const handleSubmit = React.useCallback(
() => onSubmitMessage({ text: value, attachments, cwd: "/repo" }),
[onSubmitMessage, value, attachments],
);
return (
<div
data-testid="test-composer"
data-submit-behavior={submitBehavior}
data-submit-icon={submitIcon}
>
<textarea
aria-label="Message agent..."
disabled={isDisabled}
value={value}
onChange={handleTextareaChange}
/>
<button type="button" data-testid="message-input-attach-button" disabled={isDisabled}>
Attach
</button>
{attachments.map((attachment) =>
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
<ComposerMockAttachmentPill
key={`${attachment.kind}-${attachment.item.number}`}
attachment={attachment}
attachments={attachments}
isDisabled={isDisabled}
onChangeAttachments={onChangeAttachments}
/>
) : null,
)}
<button type="button" data-testid="test-composer-submit" onClick={handleSubmit}>
Submit
</button>
</div>
);
}
vi.mock("@/components/composer", () => ({
Composer: ComposerMock,
}));
vi.mock("@/components/composer-attachments", () => ({
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
images: [],
attachments: attachments.flatMap((attachment) => {
if (attachment.kind === "github_pr") {
return [
{
type: "github_pr",
mimeType: "application/github-pr",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
if (attachment.kind === "github_issue") {
return [
{
type: "github_issue",
mimeType: "application/github-issue",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
return [];
}),
}),
}));
vi.mock("@/components/desktop/titlebar-drag-region", () => ({
TitlebarDragRegion: () => null,
}));
vi.mock("@/components/headers/menu-header", () => ({
SidebarMenuToggle: () => null,
}));
vi.mock("@/components/headers/screen-header", () => ({
ScreenHeader: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
}));
function ComboboxOptionButton({
option,
onSelect,
}: {
option: { id: string; label: string };
onSelect: (id: string) => void;
}) {
const handleClick = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
return (
<button type="button" onClick={handleClick}>
{option.label}
</button>
);
}
function ComboboxOptionRenderWrapper({
option,
onSelect,
renderOption,
}: {
option: { id: string; label: string };
onSelect: (id: string) => void;
renderOption: (input: {
option: { id: string; label: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
}) {
const handlePress = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
return (
<>
{renderOption({
option,
selected: false,
active: false,
onPress: handlePress,
})}
</>
);
}
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
options,
renderOption,
onSelect,
}: {
open?: boolean;
options: Array<{ id: string; label: string }>;
renderOption?: (input: {
option: { id: string; label: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
onSelect: (id: string) => void;
}) => {
if (!open) return null;
return (
<div data-testid="ref-picker-combobox">
{options.map((option) =>
renderOption ? (
<ComboboxOptionRenderWrapper
key={option.id}
option={option}
onSelect={onSelect}
renderOption={renderOption}
/>
) : (
<ComboboxOptionButton key={option.id} option={option} onSelect={onSelect} />
),
)}
</div>
);
},
ComboboxItem: ({
label,
onPress,
testID,
disabled,
}: {
label: string;
onPress: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onPress}>
{label}
</button>
),
}));
vi.mock("mnemonic-id", () => ({
createNameId: () => "gentle-slug",
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockClient.getBranchSuggestions.mockClear();
mockClient.searchGitHub.mockClear();
mockClient.searchGitHub.mockResolvedValue({
items: [prItem, prItemB],
githubFeaturesEnabled: true,
error: null,
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
saveDraftInputMock.mockClear();
clearDraftInputMock.mockClear();
queueDraftSubmissionMock.mockClear();
navigateMock.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function renderScreen() {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<NewWorkspaceScreen serverId="server" sourceDirectory="/repo" />
</QueryClientProvider>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function findByTestId(testID: string): Promise<HTMLElement> {
for (let attempt = 0; attempt < 10; attempt += 1) {
await flush();
const element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
if (element) return element;
}
throw new Error(`Missing element with testID ${testID}`);
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByTestId(testID: string): HTMLElement[] {
return Array.from(document.querySelectorAll(`[data-testid="${testID}"]`)) as HTMLElement[];
}
type CreatePaseoWorktreeArg = Parameters<typeof mockClient.createPaseoWorktree>[0];
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
const calls = mockClient.createPaseoWorktree.mock.calls;
const firstCall = calls[0];
if (!firstCall) throw new Error("createPaseoWorktree not called");
return firstCall[0];
}
describe("NewWorkspaceScreen picker payload", () => {
it("moves the ref picker row with the mobile keyboard shift", async () => {
renderScreen();
await flush();
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
expect(pickerRow.style.transform).toBe("translateY(-216px)");
});
it("lazily searches only GitHub PRs when the picker opens", async () => {
renderScreen();
await flush();
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
limit: 20,
kinds: ["github-pr"],
});
});
it("sends no new fields when nothing is selected (default)", async () => {
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
});
expect(call).not.toHaveProperty("refName");
expect(call).not.toHaveProperty("action");
expect(call).not.toHaveProperty("githubPrNumber");
});
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
initialDraftState.text = "please review this change";
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
expect(firstCreateWorktreeCall().firstAgentContext?.prompt).toBe("please review this change");
expect(mockClient.createAgent).not.toHaveBeenCalled();
expect(saveDraftInputMock).toHaveBeenCalledWith({
draftKey: "draft:server:draft-new-workspace",
draft: {
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
},
});
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
draftId: "draft-new-workspace",
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
provider: "claude-code",
allowEmptyText: true,
});
expect(navigateMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
target: { kind: "draft", draftId: "draft-new-workspace" },
navigationMethod: "replace",
});
expect(clearDraftInputMock).toHaveBeenCalledWith({
draftKey: "new-workspace:server:/repo",
lifecycle: "sent",
});
expect(navigateMock.mock.invocationCallOrder[0]).toBeLessThan(
clearDraftInputMock.mock.invocationCallOrder[0],
);
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
});
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const trigger = await findByTestId("new-workspace-ref-picker-trigger");
expect(trigger.textContent).toContain(`#${prItem.number}`);
expect(trigger.textContent).toContain(prItem.title);
expect(trigger.textContent).not.toContain("feature/picker");
expect(trigger.querySelectorAll('[data-icon="GitPullRequest"]')).toHaveLength(1);
});
it("adds the selected picker PR as a composer attachment pill", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItem.number}`);
expect(pills[0]?.textContent).toContain("Refactor picker");
});
it("replaces the picker-owned composer PR pill when another PR is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItemB.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItemB.number}`);
expect(pills[0]?.textContent).toContain("Polish composer chip");
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("removes the picker-owned PR pill when switching to a branch and preserves user-added chips", async () => {
initialAttachments.push({ kind: "github_issue", item: issueItem });
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${issueItem.number}`);
expect(pills[0]?.textContent).toContain(issueItem.title);
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("sends action=branch-off with the branch name when a branch row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "branch-off",
refName: "dev",
});
expect(call).not.toHaveProperty("githubPrNumber");
});
it("sends action=checkout with pr number + head ref when a github PR row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "checkout",
refName: "feature/picker",
githubPrNumber: 202,
});
});
it("sends the selected PR attachment when creating the worktree", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
const attachments = call.firstAgentContext?.attachments ?? [];
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
});
it("keeps the selected PR attachment when the PR query refreshes after selection", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
mockClient.searchGitHub.mockResolvedValue({
items: [],
githubFeaturesEnabled: true,
error: null,
});
await act(async () => {
await queryClient?.invalidateQueries({
queryKey: ["new-workspace-github-prs", "server", "/repo"],
});
});
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
expect(call.firstAgentContext?.attachments).toHaveLength(1);
expect(call.firstAgentContext?.attachments?.[0]).toMatchObject({
type: "github_pr",
number: 202,
});
});
it("omits PR rows from the picker when GitHub features are disabled", async () => {
mockClient.searchGitHub.mockResolvedValueOnce({
items: [],
githubFeaturesEnabled: false,
error: null,
});
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(queryByTestId(`new-workspace-ref-picker-pr-${prItem.number}`)).toBeNull();
expect((await findByTestId("new-workspace-ref-picker-branch-dev")).textContent).toContain(
"dev",
);
});
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
initialDraftState.text = "please review this change";
const createWorktree = createDeferredPromise<{
workspace: typeof createdWorkspace;
error: null;
}>();
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const textInput = document.querySelector('[aria-label="Message agent..."]');
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
expect(queryByTestId("test-composer")?.dataset.submitIcon).toBe("return");
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", true);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", true);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
expect(clearDraftInputMock).not.toHaveBeenCalled();
createWorktree.reject(new Error("Create worktree failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", false);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", false);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", false);
});
});
@@ -31,7 +31,6 @@ import type { ImageAttachment, MessagePayload } from "@/components/message-input
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
interface NewWorkspaceScreenProps {
serverId: string;
@@ -195,6 +194,36 @@ function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function syncPickerPrAttachment(input: {
attachments: UserComposerAttachment[];
previousPickerPrNumber: number | null;
item: PickerItem;
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
let nextAttachments = input.attachments;
let attachedPrNumber: number | null = null;
if (input.previousPickerPrNumber !== null) {
nextAttachments = nextAttachments.filter(
(attachment) =>
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
);
}
if (input.item.kind === "github-pr") {
const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some(
(attachment) =>
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
);
if (!hasExistingPrAttachment) {
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
attachedPrNumber = selectedPr.number;
}
}
return { attachments: nextAttachments, attachedPrNumber };
}
function computePickerOptionData(
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
prItems: ReadonlyArray<GitHubSearchItem>,
File diff suppressed because it is too large Load Diff
@@ -71,12 +71,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
(selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error");
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
@@ -91,7 +85,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
selectedHost={selectedHost}
onSelectHost={setSelectedServerId}
client={client}
isHostGone={isHostGone}
/>
);
}
@@ -146,7 +139,6 @@ interface ProjectSettingsBodyProps {
selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
client: DaemonClient;
isHostGone: boolean;
}
function ProjectSettingsBody({
@@ -155,7 +147,6 @@ function ProjectSettingsBody({
selectedHost,
onSelectHost,
client,
isHostGone,
}: ProjectSettingsBodyProps) {
const queryKey = useMemo(
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
@@ -201,7 +192,6 @@ function ProjectSettingsBody({
client,
onReload: handleReload,
hasMultipleHosts,
isHostGone,
})}
</View>
);
@@ -217,7 +207,6 @@ interface RenderContentInput {
client: DaemonClient;
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
}
function renderContent({
@@ -230,7 +219,6 @@ function renderContent({
client,
onReload,
hasMultipleHosts,
isHostGone,
}: RenderContentInput) {
if (readQuery.isLoading) {
return (
@@ -262,10 +250,6 @@ function renderContent({
);
}
if (isHostGone) {
return <NoEditableTarget />;
}
if (!loadedConfig) {
return (
<View style={styles.centered}>
@@ -0,0 +1,158 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentHistoryResult } from "@/hooks/use-agent-history";
import { SessionsScreen } from "@/screens/sessions-screen";
const { historyResult, navigate } = vi.hoisted(() => ({
historyResult: {
current: null as AgentHistoryResult | null,
},
navigate: vi.fn(),
}));
vi.mock("react-native", () => ({
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("div", props, children),
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("span", props, children),
}));
vi.mock("react-native-unistyles", () => {
const theme = {
spacing: { 4: 16, 6: 24 },
fontSize: { lg: 18 },
colors: {
surface0: "#111",
foregroundMuted: "#999",
},
};
return {
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
};
});
vi.mock("@react-navigation/native", () => ({
useIsFocused: () => true,
}));
vi.mock("expo-router", () => ({
router: {
navigate,
},
}));
vi.mock("lucide-react-native", () => ({
ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }),
}));
vi.mock("@/components/headers/menu-header", () => ({
MenuHeader: ({ title }: { title: string }) =>
React.createElement("header", { "data-testid": "menu-header" }, title),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) =>
React.createElement("button", { type: "button", onClick: onPress }, children),
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ color, size }: { color: string; size?: string }) =>
React.createElement("div", {
"data-color": color,
"data-size": size,
"data-testid": "sessions-loading-spinner",
}),
}));
vi.mock("@/components/agent-list", () => ({
AgentList: ({ agents }: { agents: unknown[] }) =>
React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }),
}));
vi.mock("@/hooks/use-agent-history", () => ({
useAgentHistory: () => {
if (!historyResult.current) {
throw new Error("Expected history result");
}
return historyResult.current;
},
}));
vi.mock("@/utils/host-routes", () => ({
buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`,
}));
function makeHistoryResult(overrides: Partial<AgentHistoryResult> = {}): AgentHistoryResult {
return {
agents: [],
isLoading: false,
isInitialLoad: false,
isRevalidating: false,
hasMore: false,
isLoadingMore: false,
refreshAll: vi.fn(),
loadMore: vi.fn(),
...overrides,
};
}
describe("SessionsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
navigate.mockReset();
historyResult.current = makeHistoryResult();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
historyResult.current = null;
vi.unstubAllGlobals();
});
it("shows the shared loader during the initial React Query history load", () => {
historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true });
act(() => {
root?.render(<SessionsScreen serverId="server-1" />);
});
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull();
expect(container?.textContent).not.toContain("No sessions yet");
expect(container?.textContent).not.toContain("Back");
});
it("shows the empty state after history finishes loading with no sessions", () => {
historyResult.current = makeHistoryResult();
act(() => {
root?.render(<SessionsScreen serverId="server-1" />);
});
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
expect(container?.textContent).toContain("No sessions yet");
expect(container?.textContent).toContain("Back");
});
});
@@ -0,0 +1,304 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComposerAttachment } from "@/attachments/types";
import type { MessagePayload } from "@/components/message-input";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import type { StreamItem } from "@/types/stream";
import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
const {
createAgentMock,
getCheckoutStatusMock,
onRuntimeEventMock,
latestComposerText,
latestComposerDisabled,
latestStreamText,
onCreatedMock,
} = vi.hoisted(() => ({
createAgentMock: vi.fn(),
getCheckoutStatusMock: vi.fn(),
onRuntimeEventMock: vi.fn(() => () => {}),
latestComposerText: { current: null as string | null },
latestComposerDisabled: { current: null as boolean | null },
latestStreamText: { current: null as string | null },
onCreatedMock: vi.fn(),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({
colors: {
surface0: "#000",
surface2: "#222",
destructive: "#f00",
},
spacing: { 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6 },
fontSize: { sm: 13 },
})
: factory,
},
useUnistyles: () => ({ rt: { breakpoint: "lg" } }),
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => ({
createAgent: createAgentMock,
getCheckoutStatus: getCheckoutStatusMock,
on: onRuntimeEventMock,
}),
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceExecutionAuthority: () => ({
ok: true,
authority: {
workspaceId: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
},
}),
}));
vi.mock("@/hooks/use-agent-input-draft", () => ({
useAgentInputDraft: () => {
const [text, setText] = React.useState("please review this change");
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
return {
text,
setText,
attachments,
setAttachments,
cwd: "/repo/.paseo/worktrees/workspace-1",
clear: () => {
setText("");
setAttachments([]);
},
isHydrated: true,
composerState: {
providerDefinitions: [{ id: "codex", label: "Codex" }],
selectedProvider: "codex",
selectedMode: "",
modeOptions: [],
selectedModel: "gpt-5.4",
availableModels: [{ id: "gpt-5.4", label: "GPT-5.4" }],
allProviderModels: {},
isAllModelsLoading: false,
isModelLoading: false,
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "",
featureValues: undefined,
statusControls: {
providerDefinitions: [{ id: "codex", label: "Codex" }],
selectedProvider: "codex",
modeOptions: [],
selectedMode: "",
models: [{ id: "gpt-5.4", label: "GPT-5.4" }],
selectedModel: "gpt-5.4",
thinkingOptions: [],
selectedThinkingOptionId: "",
isModelLoading: false,
allProviderModels: {},
isAllModelsLoading: false,
features: [],
},
commandDraftConfig: undefined,
persistFormPreferences: vi.fn(),
},
};
},
}));
vi.mock("@/utils/encode-images", () => ({
encodeImages: vi.fn(async () => []),
}));
vi.mock("@/components/composer-attachments", () => ({
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
images: [],
attachments,
}),
}));
vi.mock("@/attachments/composer-workspace-attachments", () => ({
composerWorkspaceAttachment: {
userAttachmentsOnly: (attachments: ComposerAttachment[]) =>
attachments.filter(
(attachment): attachment is Exclude<ComposerAttachment, { kind: "review" }> =>
attachment.kind !== "review",
),
},
}));
vi.mock("@/components/composer", () => ({
Composer: ({
value,
isSubmitLoading,
}: {
onSubmitMessage: (payload: MessagePayload) => Promise<void>;
value: string;
isSubmitLoading?: boolean;
}) => {
latestComposerText.current = value;
latestComposerDisabled.current = isSubmitLoading ?? false;
return (
<textarea
aria-label="Message agent..."
data-testid="composer"
disabled={isSubmitLoading}
readOnly
value={value}
/>
);
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: ({ streamItems }: { streamItems: StreamItem[] }) => {
latestStreamText.current =
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
},
}));
vi.mock("@/components/file-drop-zone", () => ({
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createAgentMock.mockReset();
getCheckoutStatusMock.mockResolvedValue({
cwd: "/repo/.paseo/worktrees/workspace-1",
error: null,
requestId: "checkout-status-1",
isGit: false,
isPaseoOwnedWorktree: true,
repoRoot: null,
currentBranch: null,
isDirty: false,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
});
onRuntimeEventMock.mockClear();
onCreatedMock.mockReset();
latestComposerText.current = null;
latestComposerDisabled.current = null;
latestStreamText.current = null;
useCreateFlowStore.getState().clearAll();
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {} });
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container?.remove();
container = null;
queryClient = null;
});
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function renderDraftTab() {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<WorkspaceDraftAgentTab
serverId="server"
workspaceId="workspace-1"
tabId="draft-1"
draftId="draft-1"
isPaneFocused={true}
onCreated={onCreatedMock}
onOpenWorkspaceFile={vi.fn()}
/>
</QueryClientProvider>,
);
});
}
describe("WorkspaceDraftAgentTab", () => {
it("clears the composer while an auto-submitted new-workspace prompt is shown in the stream", async () => {
const createAgent = createDeferredPromise<{ id: string }>();
createAgentMock.mockImplementationOnce(async () => await createAgent.promise);
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId: "server",
workspaceId: "workspace-1",
draftId: "draft-1",
text: "please review this change",
attachments: [],
cwd: "/repo/.paseo/worktrees/workspace-1",
provider: "codex",
model: "gpt-5.4",
allowEmptyText: true,
});
renderDraftTab();
await flush();
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(latestStreamText.current).toBe("please review this change");
expect(latestComposerDisabled.current).toBe(true);
expect(latestComposerText.current).toBe("");
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<bool
const desktopAsk = desktopApi.dialog?.ask;
if (typeof desktopAsk === "function") {
return await desktopAsk(input.message, options);
return Boolean(await desktopAsk(input.message, options));
}
return null;
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { PaseoConfigRawSchema } from "@server/utils/paseo-config-schema";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
@@ -103,7 +102,7 @@ describe("applyDraftToConfig", () => {
});
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
worktree: {
setup: "npm install",
terminals: [{ name: "dev", command: "npm run dev" }],
@@ -118,28 +117,28 @@ describe("applyDraftToConfig", () => {
},
},
customTopLevel: "preserved",
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const next = applyDraftToConfig({ draft, base });
expect((next as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as Record<string, unknown>).terminals).toEqual([
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
{ name: "dev", command: "npm run dev" },
]);
const devEntry = (next.scripts ?? {}).dev as Record<string, unknown>;
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
expect(devEntry.customScriptField).toEqual({ nested: true });
});
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
lint: { command: "npm run lint", type: "task" },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// Edit only "dev". Leave "build" and "lint" untouched.
@@ -151,37 +150,37 @@ describe("applyDraftToConfig", () => {
const scripts = next.scripts ?? {};
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
const devEntry = scripts.dev as Record<string, unknown>;
const devEntry = scripts.dev as unknown as Record<string, unknown>;
expect(devEntry.command).toBe("npm run dev -- --watch");
expect(devEntry.type).toBe("long-running");
expect(devEntry.port).toBe(3000);
expect(devEntry.customDevField).toBe("keep");
const buildEntry = scripts.build as Record<string, unknown>;
const buildEntry = scripts.build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
const lintEntry = scripts.lint as Record<string, unknown>;
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
expect(lintEntry.command).toBe("npm run lint");
expect(lintEntry.type).toBe("task");
});
it("normalizes script command text into the original command kind", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
build: { command: ["npm", "run", "build"] },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const buildRow = draft.scripts[0];
buildRow.commandText = "npm run build";
const next = applyDraftToConfig({ draft, base });
const buildEntry = (next.scripts ?? {}).build as Record<string, unknown>;
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm run build"]);
});
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
const base = PaseoConfigRawSchema.parse({});
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.scripts = [
{
@@ -204,19 +203,19 @@ describe("applyDraftToConfig", () => {
},
];
const next = applyDraftToConfig({ draft, base });
const dev = (next.scripts ?? {}).dev as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as Record<string, unknown>;
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
expect(dev.port).toBe(3000);
expect(tunnel.port).toBe("auto");
});
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
dev: { command: "npm run dev" },
build: { command: "npm run build" },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// remove build, add a row with empty name.
draft.scripts = draft.scripts
@@ -112,7 +112,7 @@ export function getWorkspaceExecutionAuthority(
reason: "workspace_missing",
message:
"workspaces" in input
? `Workspace not found: ${input.workspaceId ?? ""}`
? `Workspace not found: ${String(input.workspaceId ?? "")}`
: "Workspace not found.",
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.67",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.67",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -1,80 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242;
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
});
+1 -1
View File
@@ -432,7 +432,7 @@ async function waitForDaemonReadyWithUi(args: {
} else {
log.error(message);
}
return process.exit(1);
process.exit(1);
}
}
@@ -123,8 +123,6 @@ export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
@@ -1,105 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Regression: executable daemon launch commands must enter the supervisor.
* The worker entry remains an implementation detail of supervisor-entrypoint.
*/
import assert from "node:assert";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const repoRoot = join(import.meta.dirname, "../../..");
const serverPackagePath = join(repoRoot, "packages/server/package.json");
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
const serverConnectionOfferE2ePath = join(
repoRoot,
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
);
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
const nixPackagePath = join(repoRoot, "nix/package.nix");
function assertNoDirectWorkerLaunch(label: string, command: string): void {
assert(
!command.includes("src/server/index.ts"),
`${label} must not launch src/server/index.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/index.js"),
`${label} must not launch dist/server/server/index.js directly: ${command}`,
);
assert(
!command.includes("src/server/daemon-worker.ts"),
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/daemon-worker.js"),
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
);
}
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
assertNoDirectWorkerLaunch(label, source);
assert(
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
`${label} must not spawn ../index.ts directly`,
);
}
console.log("=== Daemon Launch Supervision Regression ===\n");
console.log("Test 1: server package scripts launch supervisor-entrypoint");
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
scripts?: Record<string, string>;
};
const startScript = serverPackage.scripts?.start ?? "";
const devScript = serverPackage.scripts?.dev ?? "";
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
assertNoDirectWorkerLaunch("server start script", startScript);
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
assertNoDirectWorkerLaunch("server dev script", devScript);
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
console.log("✓ server package scripts enter supervisor\n");
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
assert(
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
);
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
console.log("✓ app e2e setup enters supervisor\n");
console.log("Test 3: server daemon e2e process launch enters supervisor");
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
assert(
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
"server daemon e2e process launch should use supervisor-entrypoint.ts",
);
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
console.log("✓ server daemon e2e process launch enters supervisor\n");
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
assert(
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
);
assert(
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
);
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
const nixPackage = await readFile(nixPackagePath, "utf-8");
assert(
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
);
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
console.log("=== Daemon launch supervision regression test passed ===");
@@ -0,0 +1,198 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface ExitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
if (client) {
await client.close().catch(() => undefined);
return true;
}
if (Date.now() >= deadline) return false;
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid;
} catch {
return null;
}
}
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
daemonProcess.stdout?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
daemonProcess.stderr?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () =>
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
(await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined);
}
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(
exit.code,
0,
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
);
await waitFor(
async () => (await readPidLockPid(paseoHome)) === null,
15000,
"pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Unsupervised restart regression test passed ===");
+1 -6
View File
@@ -11,14 +11,10 @@
import { spawn } from "child_process";
import { $ } from "zx";
import { readdir, writeFile } from "fs/promises";
import { join, dirname, delimiter } from "path";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
// npm workspace scripts only add the local node_modules/.bin to PATH; hoisted
// packages live in the root. Prepend it so `npx paseo` resolves locally.
const rootNodeModulesBin = join(__dirname, "..", "..", "..", "node_modules", ".bin");
const args = process.argv.slice(2);
const testEnvDefaults = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
@@ -197,7 +193,6 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
const proc = spawn("npx", ["tsx", testPath], {
env: {
...process.env,
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -43,22 +43,6 @@ describe("desktop-settings", () => {
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
});
it("handles concurrent first-launch reads without racing the settings write", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await Promise.all(Array.from({ length: 20 }, () => store.get()));
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
settings: DesktopSettings;
};
const files = await readdir(userDataPath);
expect(settings).toEqual(Array.from({ length: 20 }, () => DEFAULT_DESKTOP_SETTINGS));
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
expect(files).toEqual(["desktop-settings.json"]);
});
it("coerces invalid persisted values back to safe defaults", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);

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