Compare commits

..

54 Commits

Author SHA1 Message Date
Mohamed Boudra
027b3839fc chore(release): cut 0.1.60-beta.2 2026-04-22 18:06:55 +07:00
Mohamed Boudra
c3589bdc84 chore: refresh lockfile metadata 2026-04-22 18:05:56 +07:00
Mohamed Boudra
a5e6cc2d2b docs: note beta sanity fixes 2026-04-22 18:04:32 +07:00
Mohamed Boudra
713c90ea42 fix(server): restore release-blocking lifecycle checks 2026-04-22 18:03:37 +07:00
Mohamed Boudra
e51fdada51 docs: update changelog for 0.1.60-beta.2 2026-04-22 17:58:50 +07:00
Mohamed Boudra
dd98ae9359 fix(server): checkout fork PRs into owner branches 2026-04-22 17:53:53 +07:00
Mohamed Boudra
274a7fd217 fix(server): preserve dev runner signal handling 2026-04-22 17:53:53 +07:00
Mohamed Boudra
a582fccef0 fix(app): clear new workspace drafts after submit 2026-04-22 17:53:53 +07:00
Mohamed Boudra
019bf65790 Fix running-agent replacement lifecycle 2026-04-22 17:53:53 +07:00
Mohamed Boudra
9218f70df3 Remove leftover app debug logging 2026-04-22 17:53:53 +07:00
therainisme
fd05bf8bff fix(server): preserve Codex fast mode after plan approval
Codex was clearing fast mode after approving a plan.

This keeps fast mode and plan mode independent. Approving a plan still exits plan collaboration mode, and the follow-up implementation turn now keeps serviceTier=fast when fast mode was already enabled.

Verified locally:
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts --bail=1
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/agent-manager.test.ts --bail=1
- npm run typecheck
2026-04-22 18:51:57 +08:00
Mohamed Boudra
9e3d8f0ec5 fix(cli): send version during daemon handshake 2026-04-22 16:19:51 +07:00
Mohamed Boudra
43b9123c5c fix(app): restore workspace tab focus on refresh 2026-04-22 16:19:43 +07:00
Mohamed Boudra
b6afc66131 fix(app): avoid settings header offset 2026-04-22 16:19:31 +07:00
Mohamed Boudra
7993dc02dc Add working indicator top spacing 2026-04-22 16:06:34 +07:00
Mohamed Boudra
6601db2673 Fix stream action spacing branches 2026-04-22 15:49:00 +07:00
Mohamed Boudra
e4badd95f4 Restore stream auxiliary spacing 2026-04-22 15:44:29 +07:00
Mohamed Boudra
02fc354686 Gate desktop daemon dev providers in production 2026-04-22 15:42:36 +07:00
Mohamed Boudra
aef54d5180 Optimize agent stream rendering 2026-04-22 15:38:53 +07:00
Mohamed Boudra
3c5bfd6e3d Stabilize sidebar workspace press handler 2026-04-22 14:14:34 +07:00
Mohamed Boudra
f422dabf1e Reduce redundant runtime updates 2026-04-22 14:03:59 +07:00
Mohamed Boudra
2706d5e934 fix(app): avoid duplicate bootstrap connection client 2026-04-22 13:26:45 +07:00
Mohamed Boudra
6b210df471 fix(app): stabilize startup connection routing 2026-04-22 13:11:41 +07:00
Mohamed Boudra
9a39ba4be0 Default agent ls to active scope 2026-04-22 12:52:52 +07:00
Mohamed Boudra
fc86ce2a5f Tighten Escape agent interrupt shortcut 2026-04-22 12:52:47 +07:00
Mohamed Boudra
6edf19c7c6 fix(app): streamline startup loading states 2026-04-22 12:13:23 +07:00
Mohamed Boudra
80c319dc49 Avoid git refresh for agent status updates 2026-04-22 12:07:57 +07:00
Mohamed Boudra
77c8c91ab5 Fix dev server Ctrl-C shutdown wait 2026-04-22 12:03:46 +07:00
Mohamed Boudra
6503245493 Avoid GitHub self-heal polling without remote 2026-04-22 11:21:37 +07:00
Mohamed Boudra
184902dd0d Merge branch 'perf/startup-rpc-metrics' 2026-04-22 11:03:41 +07:00
Mohamed Boudra
42cdabdf57 Fix optimistic workspace creation on Android 2026-04-22 11:03:35 +07:00
Mohamed Boudra
247983ee87 Improve startup provider snapshots and dev harness 2026-04-22 10:52:19 +07:00
Mohamed Boudra
7b9cc3d423 fix(app/e2e): use xterm buffer for alt-screen exit sync 2026-04-21 23:28:35 +07:00
github-actions[bot]
8ee3c6ae1d fix: update lockfile signatures and Nix hash 2026-04-21 16:06:25 +00:00
Mohamed Boudra
244c40a62f test(app): restore e2e playwright suite to green
- Fake gh stub now handles `gh pr view --json <fields>` by exiting 1
  with "no pull requests found for branch", matching the daemon's
  expected no-current-PR path; resolveCurrentPullRequestView no longer
  cascades failures across the suite.
- Rewrite the two post-setup script tests in workspace-setup-streaming
  to exercise explicit launch paths (UI scripts menu + daemon RPC) —
  39db3ea5 intentionally removed automatic post-setup script spawning.
- Harden terminal-alternate-screen synchronization: wait for the probe
  to observe alt-enter and alt-exit plus the final normal-screen marker
  before asserting, so the echoed command no longer races the alt-screen
  transition.
2026-04-21 23:04:57 +07:00
Mohamed Boudra
3fe63d5a2c fix(app,server): discover server-cached setup snapshots across sessions
807cce6a hides the "Show setup" action when there is no workspace setup
snapshot. That broke the case where setup genuinely ran on a different
WebSocket session than the one the browser reconnected with: the snapshot
was cached per-Session, so discovery returned null and the action stayed
hidden.

Share the setup snapshot cache at the websocket-server level and inject
it into every Session. On focus, the workspace screen now requests
fetchWorkspaceSetupStatus when it has no local snapshot; workspaces with
no setup history still return null, preserving 807cce6a's intent.
2026-04-21 23:04:47 +07:00
Mohamed Boudra
1582028902 Cache available editor discovery 2026-04-21 22:39:46 +07:00
Mohamed Boudra
7e7cc1ad56 Contain malformed ACP stdout logging 2026-04-21 22:38:12 +07:00
Mohamed Boudra
27ddfb1ea7 Fix workspace diff stats for behind branches 2026-04-21 22:03:24 +07:00
Mohamed Boudra
807cce6ad9 Hide setup action when no setup ran 2026-04-21 22:03:06 +07:00
Mohamed Boudra
73b28a7f60 Fix notification clicks preserving workspace deck 2026-04-21 21:57:15 +07:00
Mohamed Boudra
8dad423635 fix(app): remove pointer cursor on user message bubbles
The Pressable wrapping user messages is only used for hover detection,
so default to the auto cursor instead of pointer on web.
2026-04-21 21:54:45 +07:00
Mohamed Boudra
abaa29e73c ci(app): install playwright browsers before app vitest run
packages/app's vitest config has a browser project backed by playwright
+ chromium; the app-tests job needs the browsers installed the same way
the playwright job already does.
2026-04-21 21:53:23 +07:00
Mohamed Boudra
ae2a301cb6 fix(server): reflect terminalId default in workspace script schema test
workspace script schema defaults terminalId to null; include it in the
expected shape of the test's toEqual assertion.
2026-04-21 21:33:39 +07:00
Mohamed Boudra
006d57d546 fix(server): align MCP and model-resolver tests with current API
mcp-server tests invoked registered tools via .handler, but the MCP
SDK exposes the callable as .callback. model-resolver now passes
force: false to fetchModels; widen the spy matcher to match.
2026-04-21 21:29:23 +07:00
github-actions[bot]
a201e5a853 fix: update lockfile signatures and Nix hash 2026-04-21 13:50:39 +00:00
Mohamed Boudra
c402db8b1c test: add manual terminal perf harness 2026-04-21 20:47:28 +07:00
Mohamed Boudra
6a03873d81 fix: smooth terminal streaming 2026-04-21 20:47:17 +07:00
Mohamed Boudra
39db3ea5ff refactor: simplify worktree script spawning 2026-04-21 20:46:54 +07:00
Mohamed Boudra
79bf5ac3c7 test: add app browser coverage 2026-04-21 20:46:49 +07:00
Mohamed Boudra
051507717c Fix sticky workspace hover card 2026-04-21 18:17:39 +07:00
Mohamed Boudra
f3cb19529c Harden agent refresh rescue path 2026-04-21 17:22:50 +07:00
Mohamed Boudra
cea97c83b3 Reuse terminals for non-service scripts and track lifecycle via OSC 633
Scripts launched from the UI now run in a dedicated terminal per script
(titled after the script) and reuse the same terminal across re-runs.
Script lifecycle is driven by a new onCommandFinished signal sourced from
OSC 633 command-finished events emitted by the zsh shell integration, so
foreground script exit updates status without killing the terminal.
Script payloads now carry terminalId so the workspace can surface the
associated tab from the scripts dropdown.
2026-04-21 17:12:53 +07:00
Mohamed Boudra
3e2faab7f8 Fix committed diff excluding dirty worktree 2026-04-21 15:02:20 +07:00
202 changed files with 13167 additions and 1974 deletions

View File

@@ -132,6 +132,9 @@ jobs:
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight

View File

@@ -1,10 +1,47 @@
# Changelog
## 0.1.60-beta.1 - 2026-04-20
## 0.1.60-beta.2 - 2026-04-22
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
- Launch scripts and services for a worktree directly from the workspace header.
- New Setup tab in every workspace showing setup, teardown, and script progress live.
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
- Pull request pane in the workspace sidebar.
- Redesigned Settings screen with modular section navigation.
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
- New-workspace picker ranks branches by recency with fast search.
### Improved
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
- Agent streams render more smoothly during heavy tool output.
- App startup routes through a stable connection and lands on the right screen without flicker.
- Provider refresh is reliable and no longer stalls on transient failures.
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
- In-app notifications route to whichever surface you're actually looking at.
- Keyboard shortcuts keep working while Settings is open.
- Escape reliably interrupts the active agent.
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
- `paseo ls` defaults to active agents; pass `-a` to include archived.
### Fixed
- Composer textarea shrinks back down after sending on web.
- New workspace drafts clear after submit instead of sticking around.
- Replacing a running agent cleans up the previous one without leaving it behind.
- Agent notifications no longer get swallowed by a backgrounded focused client.
- Removed workspace folders disappear from the workspace list again.
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
- Workspace tab focus is preserved across page refreshes.
- Settings screen no longer pushes its header down with extra spacing.
- Branch switcher title no longer overflows on narrow rows.
- iOS image picker no longer leaves the screen unresponsive after cancelling.
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
## 0.1.59 - 2026-04-16

View File

@@ -22,6 +22,11 @@ PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
and `config.json`; actual checkout/worktree directories are not copied.
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
### Default ports

View File

@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### New Workspace Creation
The Android workspace-creation regression has a dedicated harness:
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
```
For a short recording that starts after launch/connection/sidebar setup:
```bash
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
- `android-dev-client.yaml`
- `connect-direct-if-welcome.yaml`
- `open-prepared-project-sidebar.yaml`
- `new-workspace-open-from-sidebar.yaml`
- `new-workspace-select-codex-gpt54.yaml`
- `new-workspace-submit-and-assert-created.yaml`
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."

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-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
npmDepsHash = "sha256-5exQLdsyyKhlQzrcpBKr3xidAZ3RvQ6wvzf2XyaSPJ4=";
# 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).

144
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -12771,7 +12771,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -12792,7 +12791,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -12806,7 +12804,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -12821,8 +12818,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
@@ -12882,6 +12878,20 @@
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -12972,8 +12982,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -14145,6 +14154,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/browser": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz",
"integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.6.1",
"@vitest/mocker": "3.2.4",
"@vitest/utils": "3.2.4",
"magic-string": "^0.30.17",
"sirv": "^3.0.1",
"tinyrainbow": "^2.0.0",
"ws": "^8.18.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "3.2.4",
"webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
},
"safaridriver": {
"optional": true
},
"webdriverio": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -14994,7 +15039,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -18119,8 +18163,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
@@ -27319,7 +27362,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -28679,6 +28721,18 @@
"node": ">=6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -30067,6 +30121,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-memoize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/p-memoize/-/p-memoize-8.0.0.tgz",
"integrity": "sha512-jdZ10MCxavHoIHwJ5oweOtYy6ElPixEHaMkz0AuaEMovR1MRpVvYFzIEHRxgMEpXYzNpRVByFAniAzwmd1/uug==",
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.1",
"type-fest": "^4.41.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/p-memoize?sponsor=1"
}
},
"node_modules/p-memoize/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -37303,16 +37385,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37391,6 +37473,7 @@
"@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
@@ -37456,11 +37539,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37501,11 +37584,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@getpaseo/cli": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37539,7 +37622,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37740,7 +37823,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37766,7 +37849,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37782,14 +37865,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/relay": "0.1.60-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -37808,6 +37891,7 @@
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
@@ -38543,7 +38627,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
await passivePage.close();
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id);
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
});
});

View File

@@ -6,6 +6,7 @@ import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
type WaitForServerOptions = {
host?: string;
@@ -185,6 +186,17 @@ let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (trimmed === "current") {
return resolvePaseoHomePath("~/.paseo");
}
return resolvePaseoHomePath(trimmed);
}
type OfferPayload = {
v: 2;
serverId: string;
@@ -220,6 +232,11 @@ if (args[0] === "pr" && args[1] === "list") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
@@ -322,12 +339,36 @@ export default async function globalSetup() {
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
if (forkSourceHome) {
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome: paseoHome,
});
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
console.log(
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
`${forkResult.copiedBytes} bytes)`,
);
if (forkResult.skippedMissing.length > 0) {
console.warn(
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
);
}
}
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
@@ -337,9 +378,11 @@ export default async function globalSetup() {
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome) {
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });

View File

@@ -231,6 +231,24 @@ export async function expectWorkspaceArchiveOutcome(
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
visible: true,
});
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
await closeButton.first().click();
await expectWorkspaceTabHidden(page, agentId);
}
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
await expectWorkspaceTabVisible(page, agentId);
await expect(
page.getByText("This agent is archived").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
@@ -261,6 +279,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },

View File

@@ -0,0 +1,131 @@
import { existsSync } from "node:fs";
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
export type PaseoHomeMetadataForkResult = {
sourceHome: string;
targetHome: string;
agentFiles: number;
agentBytes: number;
projectFiles: number;
projectBytes: number;
copiedFiles: number;
copiedBytes: number;
skippedMissing: string[];
};
type CopyStats = {
files: number;
bytes: number;
skippedMissing: string[];
};
export function resolvePaseoHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return path.join(homedir(), value.slice(2));
}
return path.resolve(value);
}
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
if (!existsSync(sourceDir)) {
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
}
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const entries = await readdir(sourceDir, { withFileTypes: true });
await mkdir(targetDir, { recursive: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
const nested = await copyJsonTree(sourcePath, targetPath);
stats.files += nested.files;
stats.bytes += nested.bytes;
stats.skippedMissing.push(...nested.skippedMissing);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
async function copyProjectRegistryFiles(
sourceHome: string,
targetHome: string,
): Promise<CopyStats> {
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
const sourceProjectsDir = path.join(sourceHome, "projects");
const targetProjectsDir = path.join(targetHome, "projects");
await mkdir(targetProjectsDir, { recursive: true });
for (const fileName of ["projects.json", "workspaces.json"]) {
const sourcePath = path.join(sourceProjectsDir, fileName);
const targetPath = path.join(targetProjectsDir, fileName);
if (!existsSync(sourcePath)) {
stats.skippedMissing.push(sourcePath);
continue;
}
await copyFile(sourcePath, targetPath);
const fileStat = await stat(sourcePath);
stats.files += 1;
stats.bytes += fileStat.size;
}
return stats;
}
export async function forkPaseoHomeMetadata(input: {
sourceHome: string;
targetHome: string;
}): Promise<PaseoHomeMetadataForkResult> {
const sourceHome = resolvePaseoHomePath(input.sourceHome);
const targetHome = resolvePaseoHomePath(input.targetHome);
if (sourceHome === targetHome) {
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
}
await mkdir(targetHome, { recursive: true });
// Reset only the copied metadata surface. In particular, do not copy or remove
// worktrees here: forked workspace records should continue to point at the
// original checkout/worktree paths from the source home.
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
const agents = await copyJsonTree(
path.join(sourceHome, "agents"),
path.join(targetHome, "agents"),
);
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
return {
sourceHome,
targetHome,
agentFiles: agents.files,
agentBytes: agents.bytes,
projectFiles: projects.files,
projectBytes: projects.bytes,
copiedFiles: agents.files + projects.files,
copiedBytes: agents.bytes + projects.bytes,
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
};
}

View File

@@ -0,0 +1,237 @@
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
const E2E_KEY = "@paseo:e2e";
type SavedHostInput = {
serverId: string;
label: string;
endpoint: string;
};
export function startupScenario(page: Page) {
return new StartupScenario(page);
}
class StartupScenario {
private readonly page: Page;
private savedHosts: SavedHostInput[] = [];
private desktopBridge = false;
private blockedEndpointPorts = new Set<string>();
private viewport: { width: number; height: number } | null = null;
constructor(page: Page) {
this.page = page;
}
withMobileViewport(): this {
this.viewport = { width: 390, height: 844 };
return this;
}
withSavedHost(input: SavedHostInput): this {
this.savedHosts.push(input);
const port = input.endpoint.match(/:(\d+)$/)?.[1];
if (port) {
this.blockedEndpointPorts.add(port);
}
return this;
}
withPendingDesktopDaemon(): this {
this.desktopBridge = true;
return this;
}
withBlockedPort(port: string): this {
this.blockedEndpointPorts.add(port);
return this;
}
async openRoot(): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto("/");
return new StartupAssertions(this.page);
}
async openHostWorkspace(input: {
serverId: string;
workspaceId: string;
}): Promise<StartupAssertions> {
await this.prepare();
await this.page.goto(
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
);
return new StartupAssertions(this.page);
}
private async prepare(): Promise<void> {
if (this.viewport) {
await this.page.setViewportSize(this.viewport);
}
if (this.desktopBridge) {
await installPendingDesktopBridge(this.page);
}
for (const port of this.blockedEndpointPorts) {
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
});
}
if (this.savedHosts.length === 0) {
return;
}
// Let the shared fixture create its seed nonce, then opt out of that seed for
// the next navigation so this scenario owns the stored host registry.
await this.page.goto("/");
const nowIso = new Date().toISOString();
const registry = this.savedHosts.map((host) =>
buildStoredHost({
serverId: host.serverId,
endpoint: host.endpoint,
label: host.label,
nowIso,
}),
);
const firstHost = registry[0];
if (!firstHost) {
throw new Error("Expected at least one startup test host.");
}
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry, createAgentPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding startup registry.");
}
localStorage.setItem(keys.e2e, "1");
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify(createAgentPreferences),
);
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{
keys: {
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
e2e: E2E_KEY,
registry: REGISTRY_KEY,
seedNonce: SEED_NONCE_KEY,
},
registry,
createAgentPreferences,
},
);
}
}
class StartupAssertions {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
return this;
}
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
return this;
}
async expectsNoLocalServerStartupCopy(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
0,
);
return this;
}
async expectsDesktopDaemonStartup(): Promise<this> {
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
timeout: 15_000,
});
return this;
}
async expectsSidebarHidden(): Promise<this> {
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
return this;
}
async expectsNoUndefinedRoute(): Promise<this> {
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
return this;
}
}
async function installPendingDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "start_desktop_daemon") {
await new Promise(() => {
// Keep the daemon in the startup phase until the test ends.
});
}
if (command === "desktop_daemon_status") {
return {
serverId: "srv_desktop_pending",
status: "starting",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "desktop_daemon_logs") {
return { logPath: "", contents: "" };
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
});
}
function buildStoredHost(input: {
serverId: string;
endpoint: string;
label: string;
nowIso: string;
}) {
return buildSeededHost(input);
}
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,98 @@
import type { Page } from "@playwright/test";
import { createTempGitRepo } from "./workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./terminal-perf";
type TempRepo = {
path: string;
cleanup: () => Promise<void>;
};
export type TerminalInstance = {
id: string;
name: string;
cwd: string;
};
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;
readonly tempRepo: TempRepo;
readonly workspaceId: string;
private constructor(input: {
client: TerminalPerfDaemonClient;
tempRepo: TempRepo;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.workspaceId = input.workspaceId;
}
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectTerminalClient();
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) {
await client.close().catch(() => {});
await tempRepo.cleanup().catch(() => {});
throw new Error(seedResult.error ?? "Failed to seed workspace");
}
return new TerminalE2EHarness({
client,
tempRepo,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
return result.terminal;
}
async killTerminal(terminalId: string): Promise<void> {
await this.client.killTerminal(terminalId).catch(() => {});
}
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
await navigateToTerminal(page, {
workspaceId: this.workspaceId,
terminalId: input.terminalId,
});
}
terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]');
}
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
await setupDeterministicPrompt(page, sentinel);
}
}
export async function withTerminalInApp<T>(
page: Page,
harness: TerminalE2EHarness,
input: { name: string },
fn: (terminal: TerminalInstance) => Promise<T>,
): Promise<T> {
const terminal = await harness.createTerminal({ name: input.name });
try {
await harness.openTerminal(page, { terminalId: terminal.id });
return await fn(terminal);
} finally {
await harness.killTerminal(terminal.id);
}
}

View File

@@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = {
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
title?: string;
modeId?: string;
}): Promise<{ id: string; status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;

View File

@@ -0,0 +1,680 @@
import type { Page } from "@playwright/test";
export type TerminalRenderProbeSnapshot = {
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
};
export type TerminalRenderProbeEvent = {
at: number;
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
preview?: string;
};
export type TerminalFrame = {
at: number;
rowCount: number;
nonEmptyRows: number;
firstNonEmptyRow: number | null;
text: string;
topText: string;
};
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
frameCount: number;
};
export type TerminalKeystrokeStressReport = {
inputTextLength: number;
keydownCount: number;
inputFrameCount: number;
outputFrameCount: number;
textMessageFrameCount: number;
textMessagePayloadBytes: number;
largeTextMessageCount: number;
largestTextMessageBytes: number;
agentStreamTextMessageCount: number;
agentStreamTextMessagePayloadBytes: number;
largeAgentStreamTextMessageCount: number;
largestAgentStreamTextMessageBytes: number;
appEventCount: number;
appEventCounts: Record<string, number>;
runtimeMaxQueueDepth: number;
xtermWriteCount: number;
inputFramePayloadBytes: number;
outputFramePayloadBytes: number;
keydownToInputFrameMs: LatencyStats | null;
inputFrameToOutputFrameMs: LatencyStats | null;
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
appTerminalEmitListenerDurationMs: LatencyStats | null;
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
outputFrameToXtermWriteMs: LatencyStats | null;
xtermWriteDurationMs: LatencyStats | null;
keydownToXtermCommitMs: LatencyStats | null;
firstKeydownAt: number | null;
lastXtermCommitAt: number | null;
};
export type LatencyStats = {
count: number;
minMs: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
avgMs: number;
};
export async function installTerminalRenderProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
type ProbeState = {
term: any;
setCount: number;
unsetCount: number;
writeCount: number;
resetWrites: number;
clearScreenWrites: number;
altEnterWrites: number;
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
rafId: number | null;
sampleUntil: number;
reset: () => void;
snapshot: () => TerminalRenderProbeSnapshot;
startSampling: (durationMs: number) => void;
};
const win = window as any;
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
const probe: ProbeState = {
term: getExisting(),
setCount: 0,
unsetCount: 0,
writeCount: 0,
resetWrites: 0,
clearScreenWrites: 0,
altEnterWrites: 0,
altExitWrites: 0,
events: [],
frames: [],
rafId: null,
sampleUntil: 0,
reset() {
this.setCount = 0;
this.unsetCount = 0;
this.writeCount = 0;
this.resetWrites = 0;
this.clearScreenWrites = 0;
this.altEnterWrites = 0;
this.altExitWrites = 0;
this.events = [];
this.frames = [];
},
snapshot() {
return {
setCount: this.setCount,
unsetCount: this.unsetCount,
writeCount: this.writeCount,
resetWrites: this.resetWrites,
clearScreenWrites: this.clearScreenWrites,
altEnterWrites: this.altEnterWrites,
altExitWrites: this.altExitWrites,
events: this.events,
frames: this.frames,
};
},
startSampling(durationMs: number) {
this.sampleUntil = performance.now() + durationMs;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
const sample = () => {
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
(row) => row.textContent ?? "",
);
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
this.frames.push({
at: performance.now(),
rowCount: rows.length,
nonEmptyRows: nonEmptyRows.length,
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
text: rows.join("\n"),
topText: rows.slice(0, 3).join("\n"),
});
if (performance.now() < this.sampleUntil) {
this.rafId = requestAnimationFrame(sample);
} else {
this.rafId = null;
}
};
this.rafId = requestAnimationFrame(sample);
},
};
Object.defineProperty(win, "__terminalRenderProbe", {
configurable: true,
value: probe,
});
Object.defineProperty(win, "__paseoTerminal", {
configurable: true,
get() {
return probe.term;
},
set(next: any) {
if (next === undefined) {
probe.unsetCount += 1;
probe.events.push({ at: performance.now(), type: "unset" });
probe.term = next;
return;
}
probe.setCount += 1;
probe.events.push({ at: performance.now(), type: "set" });
probe.term = next;
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
probe.writeCount += 1;
const preview = text
.replace(/\u001b/g, "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.slice(0, 160);
if (text.includes("\u001bc")) {
probe.resetWrites += 1;
probe.events.push({ at: performance.now(), type: "reset-write", preview });
}
if (text.includes("\u001b[2J")) {
probe.clearScreenWrites += 1;
probe.events.push({ at: performance.now(), type: "clear-write", preview });
}
if (text.includes("\u001b[?1049h")) {
probe.altEnterWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
}
if (text.includes("\u001b[?1049l")) {
probe.altExitWrites += 1;
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
}
return originalWrite(data, callback);
};
next.__paseoRenderProbeWriteWrapped = true;
}
},
});
});
}
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as any).__terminalRenderProbe.reset();
});
}
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
await page.evaluate((ms) => {
(window as any).__terminalRenderProbe.startSampling(ms);
}, durationMs);
}
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
}
export async function terminalVisibleText(page: Page): Promise<string> {
return page.evaluate(() =>
Array.from(document.querySelectorAll(".xterm-rows > div"))
.map((row) => row.textContent ?? "")
.join("\n"),
);
}
export function summarizeTerminalRenderProbe(
probe: TerminalRenderProbeSnapshot,
): TerminalRenderProbeSummary {
return {
setCount: probe.setCount,
unsetCount: probe.unsetCount,
writeCount: probe.writeCount,
resetWrites: probe.resetWrites,
clearScreenWrites: probe.clearScreenWrites,
altEnterWrites: probe.altEnterWrites,
altExitWrites: probe.altExitWrites,
events: probe.events,
frameCount: probe.frames.length,
};
}
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
type TimedTextEvent = {
at: number;
text: string;
bytes: number;
};
type TimedTextMessageEvent = {
at: number;
bytes: number;
kind: string | null;
};
type XtermWriteEvent = {
at: number;
committedAt: number | null;
text: string;
bytes: number;
};
type AppProbeEvent = {
type: string;
at: number;
bytes?: number;
queueDepth?: number;
};
type StressProbeState = {
keydowns: Array<{ at: number; key: string }>;
inputFrames: TimedTextEvent[];
outputFrames: TimedTextEvent[];
textMessageFrames: TimedTextMessageEvent[];
xtermWrites: XtermWriteEvent[];
appEvents: AppProbeEvent[];
reset: () => void;
report: (inputText: string) => TerminalKeystrokeStressReport;
};
const INPUT_OPCODE = 0x02;
const OUTPUT_OPCODE = 0x01;
const decoder = new TextDecoder();
function bytesFrom(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
const bytes = bytesFrom(data);
if (bytes) return Promise.resolve(bytes);
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
}
return Promise.resolve(null);
}
function frameText(bytes: Uint8Array): string {
return decoder.decode(bytes.slice(2));
}
function eventDataText(data: unknown): Promise<string | null> {
if (typeof data === "string") {
return Promise.resolve(data);
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return data.text();
}
return Promise.resolve(null);
}
function textMessageKind(text: string): string | null {
try {
const parsed = JSON.parse(text) as {
type?: unknown;
message?: {
type?: unknown;
};
};
if (typeof parsed.type !== "string") {
return null;
}
if (typeof parsed.message?.type === "string") {
return `${parsed.type}:${parsed.message.type}`;
}
return parsed.type;
} catch {
return null;
}
}
function summarize(values: number[]): LatencyStats | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
};
const total = values.reduce((sum, value) => sum + value, 0);
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
return events.find((event) => event.at >= at) ?? null;
}
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
return (
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
null
);
}
function countByType(events: AppProbeEvent[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of events) {
counts[event.type] = (counts[event.type] ?? 0) + 1;
}
return counts;
}
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
return events.filter((event) => event.type === type);
}
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
const count = Math.min(from.length, to.length);
const values: number[] = [];
for (let index = 0; index < count; index += 1) {
values.push(to[index]!.at - from[index]!.at);
}
return values;
}
const probe: StressProbeState = {
keydowns: [],
inputFrames: [],
outputFrames: [],
textMessageFrames: [],
xtermWrites: [],
appEvents: [],
reset() {
this.keydowns = [];
this.inputFrames = [];
this.outputFrames = [];
this.textMessageFrames = [];
this.xtermWrites = [];
this.appEvents = [];
},
report(inputText: string) {
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
const keydownToInputFrame = this.keydowns
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
.filter((at): at is number => at !== null)
.map((at, index) => at - this.keydowns[index]!.at);
const inputFrameToOutputFrame = this.inputFrames
.map((input) => {
const output = firstAtOrAfter(this.outputFrames, input.at);
return output ? output.at - input.at : null;
})
.filter((value): value is number => value !== null);
const outputFrameToXtermWrite = this.outputFrames
.map((output) => {
const write = firstAtOrAfter(this.xtermWrites, output.at);
return write ? write.at - output.at : null;
})
.filter((value): value is number => value !== null);
const xtermWriteDurations = this.xtermWrites
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
.filter((value): value is number => value !== null);
const keydownToXtermCommit = this.keydowns
.map((keydown) => {
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
return write?.committedAt ? write.committedAt - keydown.at : null;
})
.filter((value): value is number => value !== null);
return {
inputTextLength: inputText.length,
keydownCount: this.keydowns.length,
inputFrameCount: this.inputFrames.length,
outputFrameCount: this.outputFrames.length,
textMessageFrameCount: this.textMessageFrames.length,
textMessagePayloadBytes: this.textMessageFrames.reduce(
(sum, frame) => sum + frame.bytes,
0,
),
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
.length,
largestTextMessageBytes: Math.max(
0,
...this.textMessageFrames.map((frame) => frame.bytes),
),
agentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream",
).length,
agentStreamTextMessagePayloadBytes: this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.reduce((sum, frame) => sum + frame.bytes, 0),
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
).length,
largestAgentStreamTextMessageBytes: Math.max(
0,
...this.textMessageFrames
.filter((frame) => frame.kind === "session:agent_stream")
.map((frame) => frame.bytes),
),
appEventCount: this.appEvents.length,
appEventCounts: countByType(this.appEvents),
runtimeMaxQueueDepth: Math.max(
0,
...this.appEvents
.map((event) => event.queueDepth)
.filter((value): value is number => typeof value === "number"),
),
xtermWriteCount: this.xtermWrites.length,
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
keydownToInputFrameMs: summarize(keydownToInputFrame),
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
appBinaryReceivedToFrameDecodedMs: summarize(
latencyByIndex(binaryReceived, frameDecoded),
),
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
appTerminalEmitListenerDurationMs: summarize(
latencyByIndex(terminalEmit, terminalEmitted),
),
appTerminalEmitToStreamControllerOutputMs: summarize(
latencyByIndex(terminalEmit, streamControllerOutput),
),
appStreamControllerDecodeToOnOutputMs: summarize(
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
),
appStreamControllerToEmulatorWriteMs: summarize(
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
),
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
),
appRuntimeEnqueuedToOperationStartMs: summarize(
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
),
appRuntimeOperationStartToXtermWriteMs: summarize(
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
),
appRuntimeXtermWriteToCommitMs: summarize(
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
),
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
),
appBinaryReceivedToRuntimeOperationStartMs: summarize(
latencyByIndex(binaryReceived, runtimeOperationStart),
),
appBinaryReceivedToXtermCommitMs: summarize(
latencyByIndex(binaryReceived, runtimeXtermCommitted),
),
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
xtermWriteDurationMs: summarize(xtermWriteDurations),
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
firstKeydownAt: this.keydowns[0]?.at ?? null,
lastXtermCommitAt:
this.xtermWrites
.map((write) => write.committedAt)
.filter((at): at is number => typeof at === "number")
.at(-1) ?? null,
};
},
};
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
configurable: true,
value: probe,
});
document.addEventListener(
"keydown",
(event) => {
if (event.key.length === 1) {
probe.keydowns.push({ at: performance.now(), key: event.key });
}
},
true,
);
const NativeWebSocket = window.WebSocket;
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
super.addEventListener("message", (event) => {
void eventDataBytes(event.data).then((bytes) => {
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
return;
}
probe.outputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
});
void eventDataText(event.data).then((text) => {
if (text === null) {
return;
}
probe.textMessageFrames.push({
at: performance.now(),
bytes: new TextEncoder().encode(text).byteLength,
kind: textMessageKind(text),
});
});
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
const bytes = bytesFrom(data);
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
probe.inputFrames.push({
at: performance.now(),
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
}
super.send(data);
}
}
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
value: NativeWebSocket.CONNECTING,
});
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
let terminal = getExisting();
Object.defineProperty(window, "__paseoTerminal", {
configurable: true,
get() {
return terminal;
},
set(next: any) {
terminal = next;
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
next.write = (data: string | Uint8Array, callback?: () => void) => {
const text =
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
const event: XtermWriteEvent = {
at: performance.now(),
committedAt: null,
text,
bytes: text.length,
};
probe.xtermWrites.push(event);
return originalWrite(data, () => {
event.committedAt = performance.now();
callback?.();
});
};
next.__paseoKeystrokeProbeWriteWrapped = true;
}
},
});
});
}
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as any).__terminalKeystrokeStressProbe.reset();
});
}
export async function readTerminalKeystrokeStressReport(
page: Page,
inputText: string,
): Promise<TerminalKeystrokeStressReport> {
return page.evaluate(
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
inputText,
);
}

View File

@@ -1,7 +1,7 @@
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,
@@ -28,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>();
@@ -191,7 +293,7 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect(composer).toBeVisible({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
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();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0);
agentCreatedDelay.release();
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(1, { timeout: 30_000 });
} finally {
agentCreatedDelay.release();
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {

View File

@@ -0,0 +1,172 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
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 pressSettingsToggleShortcut(page: Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
}
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
if (!raw) {
return null;
}
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
})
.toBe(expected);
}
async function openAgentRouteAndExpectFocused(input: {
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),
);
await input.page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForTabBar(input.page);
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", () => {
test.describe.configure({ timeout: 180_000 });
test("toggling settings after changing a setting returns to the same workspace tab", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("settings-toggle-tab-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `settings-toggle-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
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$/);
await page.getByRole("button", { name: "Queue", exact: true }).click();
await expectSendBehavior(page, "queue");
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
await waitForTabBar(page);
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 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);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
page,
}) => {
const serverId = getServerId();
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("agent-route-refresh-");
const agentIds: string[] = [];
try {
const firstAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(client, {
cwd: repo.path,
title: `agent-route-refresh-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: repo.path,
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 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);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,64 @@
import { test } from "./fixtures";
import { startupScenario } from "./helpers/startup-dsl";
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return port;
}
function getE2EServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Startup loading presentation", () => {
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
const startup = await startupScenario(page)
.withMobileViewport()
.withSavedHost({
serverId: "srv_unreachable_mobile",
label: "Dev",
endpoint: "127.0.0.1:45678",
})
.openRoot();
await startup.expectsReconnectWelcome();
await startup.expectsNoSavedHostStatus({ label: "Dev" });
await startup.expectsNoLocalServerStartupCopy();
});
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
page,
}) => {
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openRoot();
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
await startup.expectsNoUndefinedRoute();
});
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
page,
}) => {
const serverId = getE2EServerId();
const startup = await startupScenario(page)
.withPendingDesktopDaemon()
.withBlockedPort(getE2EDaemonPort())
.openHostWorkspace({
serverId,
workspaceId: "workspace-1",
});
await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden();
});
});

View File

@@ -0,0 +1,537 @@
import { Buffer } from "node:buffer";
import type { CDPSession, Page, TestInfo } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
type ParsedWireMessage = {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
};
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
direction: WireDirection;
bytes: number;
};
type ProviderSnapshotWireEntry = {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
};
type WebSocketFrameEvent = {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
};
type WireSummary = {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
byPhase: Record<
WirePhase,
{
frames: number;
bytes: number;
byType: Array<{ type: string; frames: number; bytes: number }>;
rpcCounts: Array<{ requestType: string; count: number }>;
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
}
>;
fetchPages: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
entries: number | null;
hasMore: boolean | null;
bytes: number;
}>;
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
providerSnapshots: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
totalModels: number;
totalModes: number;
bytes: number;
providers: ProviderSnapshotWireEntry[];
}>;
providerSnapshotTotals: Array<{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: string[];
}>;
fork: {
sourceHome: string | null;
targetHome: string | null;
copiedFiles: number | null;
copiedBytes: number | null;
};
};
class WireMonitor {
private phase: WirePhase = "startup";
private session: CDPSession | null = null;
readonly records: WireFrameRecord[] = [];
async start(page: Page): Promise<void> {
this.session = await page.context().newCDPSession(page);
await this.session.send("Network.enable");
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
this.record("sent", event);
});
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
this.record("received", event);
});
}
setPhase(phase: WirePhase): void {
this.phase = phase;
}
hasCompletedStartupFetches(): boolean {
return (
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_agents_response" &&
record.hasMore === false,
) &&
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_workspaces_response" &&
record.hasMore === false,
)
);
}
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
return {
totalFrames: this.records.length,
totalBytes: sumBytes(this.records),
byDirection: {
sent: summarizeDirection(this.records, "sent"),
received: summarizeDirection(this.records, "received"),
},
byPhase: {
startup: summarizePhase(this.records, "startup"),
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
},
fetchPages: this.records
.filter(
(record) =>
record.direction === "received" &&
(record.type === "fetch_agents_response" ||
record.type === "fetch_workspaces_response"),
)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
entries: record.entryCount,
hasMore: record.hasMore,
bytes: record.bytes,
})),
clickedWorkspaces,
providerSnapshots: this.records
.filter((record) => record.direction === "received" && record.providerEntries)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
totalModels: sumProviderModels(record.providerEntries ?? []),
totalModes: sumProviderModes(record.providerEntries ?? []),
bytes: record.bytes,
providers: record.providerEntries ?? [],
})),
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
fork: {
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
},
};
}
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
if (event.response.opcode !== 1) {
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData),
type: `opcode:${event.response.opcode}`,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
});
return;
}
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
...parseWireMessage(event.response.payloadData),
});
}
}
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseWireMessage(payloadData: string): ParsedWireMessage {
try {
const parsed = JSON.parse(payloadData) as unknown;
if (!parsed || typeof parsed !== "object") {
return emptyParsedWireMessage();
}
const envelope = parsed as {
type?: unknown;
message?: unknown;
};
const unwrapped =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? envelope.message
: parsed;
const message = unwrapped as {
type?: unknown;
requestId?: unknown;
payload?: {
requestId?: unknown;
entries?: unknown;
agents?: unknown;
pageInfo?: { hasMore?: unknown };
};
};
const entries = readMessageEntries(message.payload);
const hasMore = readMessageHasMore(message.payload);
const messageType = typeof message.type === "string" ? message.type : null;
const providerEntries = isProviderSnapshotMessage(messageType)
? parseProviderSnapshotEntries(message.payload)
: null;
return {
type: messageType,
requestId: readMessageRequestId(message),
entryCount: entries ? entries.length : null,
hasMore,
providerEntries,
};
} catch {
return emptyParsedWireMessage();
}
}
function readMessageRequestId(message: {
requestId?: unknown;
payload?: { requestId?: unknown };
}): string | null {
if (typeof message.requestId === "string") {
return message.requestId;
}
if (typeof message.payload?.requestId === "string") {
return message.payload.requestId;
}
return null;
}
function isProviderSnapshotMessage(messageType: string | null): boolean {
return (
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
);
}
function readMessageEntries(
payload:
| {
entries?: unknown;
agents?: unknown;
}
| undefined,
): unknown[] | null {
if (Array.isArray(payload?.entries)) {
return payload.entries;
}
if (Array.isArray(payload?.agents)) {
return payload.agents;
}
return null;
}
function readMessageHasMore(
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
): boolean | null {
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
}
function emptyParsedWireMessage(): ParsedWireMessage {
return {
type: null,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
};
}
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
if (!payload || typeof payload !== "object") {
return null;
}
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) {
return null;
}
const providerEntries: ProviderSnapshotWireEntry[] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const provider = (entry as { provider?: unknown }).provider;
if (typeof provider !== "string") {
continue;
}
const models = (entry as { models?: unknown }).models;
const modes = (entry as { modes?: unknown }).modes;
const status = (entry as { status?: unknown }).status;
providerEntries.push({
provider,
status: typeof status === "string" ? status : null,
modelCount: Array.isArray(models) ? models.length : 0,
modeCount: Array.isArray(modes) ? modes.length : 0,
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
});
}
return providerEntries.length > 0 ? providerEntries : null;
}
function sumBytes(records: WireFrameRecord[]): number {
return records.reduce((sum, record) => sum + record.bytes, 0);
}
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
const selected = records.filter((record) => record.direction === direction);
return {
frames: selected.length,
bytes: sumBytes(selected),
};
}
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
const selected = records.filter((record) => record.phase === phase);
return {
frames: selected.length,
bytes: sumBytes(selected),
byType: summarizeByType(selected),
rpcCounts: summarizeRpcCounts(selected),
rpcs: summarizeRpcs(selected),
};
}
function summarizeByType(records: WireFrameRecord[]): Array<{
type: string;
frames: number;
bytes: number;
}> {
const byType = new Map<string, { frames: number; bytes: number }>();
for (const record of records) {
const type = record.type ?? "unknown";
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
current.frames += 1;
current.bytes += record.bytes;
byType.set(type, current);
}
return [...byType.entries()]
.map(([type, value]) => ({ type, ...value }))
.sort((left, right) => right.bytes - left.bytes);
}
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
const responsesByRequestId = new Map<string, string>();
for (const record of records) {
if (record.direction !== "received" || !record.requestId) {
continue;
}
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
}
return records
.filter((record) => record.direction === "sent" && record.requestId && record.type)
.map((record) => ({
requestType: record.type ?? "unknown",
requestId: record.requestId ?? "",
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
}));
}
function summarizeRpcCounts(
records: WireFrameRecord[],
): Array<{ requestType: string; count: number }> {
const counts = new Map<string, number>();
for (const record of records) {
if (record.direction !== "sent" || !record.type || !record.requestId) {
continue;
}
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
}
return [...counts.entries()]
.map(([requestType, count]) => ({ requestType, count }))
.sort(
(left, right) =>
right.count - left.count || left.requestType.localeCompare(right.requestType),
);
}
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
}
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
}
function summarizeProviderSnapshots(
records: WireFrameRecord[],
): WireSummary["providerSnapshotTotals"] {
const byProvider = new Map<
string,
{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: Set<string>;
}
>();
for (const record of records) {
if (record.direction !== "received" || !record.providerEntries) {
continue;
}
for (const entry of record.providerEntries) {
const key = `${record.phase}:${entry.provider}`;
const current = byProvider.get(key) ?? {
phase: record.phase,
provider: entry.provider,
frames: 0,
bytes: 0,
maxModels: 0,
maxModes: 0,
statuses: new Set<string>(),
};
current.frames += 1;
current.bytes += entry.bytes;
current.maxModels = Math.max(current.maxModels, entry.modelCount);
current.maxModes = Math.max(current.maxModes, entry.modeCount);
if (entry.status) {
current.statuses.add(entry.status);
}
byProvider.set(key, current);
}
}
return [...byProvider.values()]
.map((entry) => ({
phase: entry.phase,
provider: entry.provider,
frames: entry.frames,
bytes: entry.bytes,
maxModels: entry.maxModels,
maxModes: entry.maxModes,
statuses: [...entry.statuses].sort(),
}))
.sort((left, right) => right.bytes - left.bytes);
}
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
await testInfo.attach("startup-wire-metrics.json", {
body: JSON.stringify(summary, null, 2),
contentType: "application/json",
});
}
test.describe("ad hoc startup wire metrics", () => {
test.skip(
process.env.E2E_WIRE_METRICS !== "1",
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
);
test("measures startup hydration and workspace navigation websocket traffic", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const monitor = new WireMonitor();
await monitor.start(page);
await gotoAppShell(page);
await waitForSidebarHydration(page, 120_000);
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
await page.waitForTimeout(1_000);
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll((elements) =>
elements
.slice(0, 3)
.map((element) => element.getAttribute("data-testid"))
.filter((value): value is string => Boolean(value)),
);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
for (const testId of workspaceTestIds) {
const row = page.getByTestId(testId);
if (!(await row.isVisible().catch(() => false))) {
continue;
}
const beforeFrames = monitor.records.length;
const beforeBytes = sumBytes(monitor.records);
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.waitForTimeout(1_000);
clickedWorkspaces.push({
testId,
frames: monitor.records.length - beforeFrames,
bytes: sumBytes(monitor.records) - beforeBytes,
});
}
const summary = monitor.summarize(clickedWorkspaces);
await attachSummary(testInfo, summary);
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
console.log(JSON.stringify(summary, null, 2));
console.log("PASEO_STARTUP_WIRE_METRICS_END");
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
expect(clickedWorkspaces.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,135 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
resetTerminalRenderProbe,
startTerminalFrameSampling,
summarizeTerminalRenderProbe,
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
try {
await expect
.poll(
async () => {
lastBufferText = await getTerminalBufferText(page);
lastProbe = await readTerminalRenderProbe(page);
return (
lastProbe.altEnterWrites > 0 &&
lastProbe.altExitWrites > 0 &&
lastBufferText.includes(afterAlt)
);
},
{
intervals: [50],
message: `wait for alternate-screen exit and ${afterAlt} output`,
timeout,
},
)
.toBe(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
{
afterAlt,
probe: summarizeTerminalRenderProbe(lastProbe),
bufferTextTail: lastBufferText.slice(-500),
},
null,
2,
)}`,
);
}
return lastProbe;
}
test.describe("Terminal alternate-screen transitions", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
page,
}, testInfo) => {
test.setTimeout(60_000);
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
const historyReady = `HISTORY_READY_${Date.now()}`;
await terminal.pressSequentially(
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
{ delay: 0 },
);
await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
await resetTerminalRenderProbe(page);
await page.waitForTimeout(500);
const settledProbe = await readTerminalRenderProbe(page);
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
0,
);
await resetTerminalRenderProbe(page);
const afterAlt = `AFTER_ALT_${Date.now()}`;
await startTerminalFrameSampling(page);
await terminal.pressSequentially(
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
{ delay: 0 },
);
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
const probeSummary = summarizeTerminalRenderProbe(probe);
await testInfo.attach("alternate-screen-probe", {
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
contentType: "application/json",
});
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
expect(
probe.altEnterWrites,
"test command should enter the alternate screen",
).toBeGreaterThan(0);
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
0,
);
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
const finalBufferText = await getTerminalBufferText(page);
expect(finalBufferText).toContain(historyReady);
expect(finalBufferText).toContain(afterAlt);
const suspiciousFrames = probe.frames.filter(
(frame) =>
frame.text.includes("$") &&
!frame.text.includes(historyReady) &&
!frame.text.includes(afterAlt) &&
frame.nonEmptyRows <= 2 &&
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
);
expect(
suspiciousFrames,
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
).toEqual([]);
});
});
});

View File

@@ -0,0 +1,350 @@
import { performance } from "node:perf_hooks";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
import {
installTerminalKeystrokeStressProbe,
readTerminalKeystrokeStressReport,
resetTerminalKeystrokeStressProbe,
type LatencyStats,
} from "./helpers/terminal-probes";
import { waitForTerminalContent } from "./helpers/terminal-perf";
const INPUT_TEXT = buildStressText(600);
const BIG_DIFF_BYTES = 256_000;
const SMALL_AGENT_STREAM_UPDATES = 1000;
const STRESS_TIMEOUT_MS = 15_000;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
type DaemonEchoReport = {
inputTextLength: number;
inputFrameCount: number;
outputEventCount: number;
echoedBytes: number;
sendToOutputMs: LatencyStats;
firstSendAt: number;
lastOutputAt: number;
};
terminalPerfDescribe("Terminal keystroke stress", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("logs daemon-only and app keystroke echo latency under burst input", async ({
page,
}, testInfo) => {
test.setTimeout(75_000);
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
try {
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
} finally {
await harness.killTerminal(daemonTerminal.id);
}
await installTerminalKeystrokeStressProbe(page);
const appBaselineReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress",
});
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
const appSmallChunksReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-small-agent-chunks",
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
});
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
SMALL_AGENT_STREAM_UPDATES,
);
const appBigDiffReport = await measureAppBurstEcho({
page,
harness,
terminalName: "app-keystroke-stress-big-diff",
bigDiffBytes: BIG_DIFF_BYTES,
});
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
BIG_DIFF_BYTES,
);
});
});
async function measureAppBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
bigDiffBytes?: number;
agentStreamUpdateCount?: number;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
const agent =
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
? null
: await input.harness.client.createAgent({
provider: "mock",
cwd: input.harness.tempRepo.path,
title: "Large WebSocket payload",
modeId: "load-test",
});
const bigDiffBytes = input.bigDiffBytes;
const agentStreamUpdateCount = input.agentStreamUpdateCount;
const startAgentLoad =
agent === null
? null
: () => {
if (bigDiffBytes !== undefined) {
return emitLargeDiffAgentPayload(input.harness, {
agentId: agent.id,
bytes: bigDiffBytes,
});
}
if (agentStreamUpdateCount !== undefined) {
return emitRapidAgentStreamUpdates(input.harness, {
agentId: agent.id,
count: agentStreamUpdateCount,
});
}
return Promise.resolve();
};
const terminal = input.harness.terminalSurface(input.page);
await terminal.press("Control+c");
await input.page.waitForTimeout(200);
await resetTerminalKeystrokeStressProbe(input.page);
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
if (input.bigDiffBytes !== undefined) {
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
}
if (input.agentStreamUpdateCount !== undefined) {
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
}
await activeAgentLoadPromise;
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function emitRapidAgentStreamUpdates(
harness: TerminalE2EHarness,
input: { agentId: string; count: number },
): Promise<void> {
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
}
async function emitLargeDiffAgentPayload(
harness: TerminalE2EHarness,
input: { agentId: string; bytes: number },
): Promise<void> {
await harness.client.sendAgentMessage(
input.agentId,
`emit ${input.bytes} byte large diff agent stream update`,
);
}
async function measureDaemonBurstEcho(
harness: TerminalE2EHarness,
terminal: TerminalInstance,
inputText: string,
): Promise<DaemonEchoReport> {
await harness.client.subscribeTerminal(terminal.id);
const outputTimesByByte: number[] = [];
let outputEventCount = 0;
let echoedBytes = 0;
const decoder = new TextDecoder();
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
return;
}
outputEventCount += 1;
const text = decoder.decode(event.data);
const now = performance.now();
const previousEchoedBytes = echoedBytes;
echoedBytes += text.length;
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
outputTimesByByte[index] = now;
}
});
try {
await new Promise((resolve) => setTimeout(resolve, 250));
echoedBytes = 0;
outputTimesByByte.length = 0;
const sendTimes: number[] = [];
for (const char of inputText) {
sendTimes.push(performance.now());
harness.client.sendTerminalInput(terminal.id, {
type: "input",
data: char,
});
}
await waitForDaemonEchoBytes({
getEchoedBytes: () => echoedBytes,
expectedBytes: inputText.length,
timeoutMs: STRESS_TIMEOUT_MS,
});
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
return {
inputTextLength: inputText.length,
inputFrameCount: sendTimes.length,
outputEventCount,
echoedBytes,
sendToOutputMs: summarizeLatency(latencies),
firstSendAt: sendTimes[0] ?? 0,
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
};
} finally {
unsubscribe();
}
}
async function waitForDaemonEchoBytes(input: {
getEchoedBytes: () => number;
expectedBytes: number;
timeoutMs: number;
}): Promise<void> {
const deadline = Date.now() + input.timeoutMs;
while (Date.now() < deadline) {
if (input.getEchoedBytes() >= input.expectedBytes) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
);
}
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
const tail = text.slice(-80);
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
}
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.outputFramePayloadBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
}
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
);
}
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
const deadline = Date.now() + STRESS_TIMEOUT_MS;
while (Date.now() < deadline) {
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
if (report.agentStreamTextMessageCount >= expectedCount) {
return;
}
await page.waitForTimeout(25);
}
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
throw new Error(
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
);
}
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
await testInfo.attach(name, {
body: JSON.stringify(value, null, 2),
contentType: "application/json",
});
}
function buildStressText(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let output = "";
while (output.length < length) {
output += alphabet;
}
return output.slice(0, length);
}
function summarizeLatency(values: number[]): LatencyStats {
const sorted = [...values].sort((a, b) => a - b);
const percentile = (p: number) => {
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
};
const total = values.reduce((sum, value) => sum + value, 0);
return {
count: values.length,
minMs: round2(sorted[0] ?? 0),
p50Ms: round2(percentile(50)),
p95Ms: round2(percentile(95)),
maxMs: round2(sorted[sorted.length - 1] ?? 0),
avgMs: round2(total / values.length),
};
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
test.describe("Terminal wire performance", () => {
terminalPerfDescribe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;

View File

@@ -139,6 +139,35 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId,
agentId,
reason: "finished",
},
},
cancelable: true,
}),
);
},
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
serverId,

View File

@@ -19,6 +19,18 @@ function getServerId(): string {
return serverId;
}
type WorkspaceScriptStarter = {
startWorkspaceScript(
workspaceId: string,
scriptName: string,
): Promise<{
workspaceId: string;
scriptName: string;
terminalId: string | null;
error: string | null;
}>;
};
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
@@ -226,7 +238,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches script terminals after setup completes", async ({ page }) => {
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
@@ -263,12 +275,15 @@ test.describe("Workspace setup streaming", () => {
await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the script name.
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.keyboard.press("Escape");
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
@@ -294,7 +309,7 @@ test.describe("Workspace setup streaming", () => {
}
});
test("launches workspace scripts after setup completes", async () => {
test("launches workspace scripts through an explicit daemon request", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
@@ -303,7 +318,7 @@ test.describe("Workspace setup streaming", () => {
},
scripts: {
editor: {
command: "npm run dev",
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
},
},
},
@@ -325,9 +340,19 @@ test.describe("Workspace setup streaming", () => {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
await completed;
const scriptClient = client as typeof client & WorkspaceScriptStarter;
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
expect(startResult).toMatchObject({
workspaceId,
scriptName: "editor",
terminalId: expect.any(String),
error: null,
});
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);

View File

@@ -0,0 +1,71 @@
# Maestro Flows
This directory contains local mobile UI flows. Keep flows small enough that a
failure screenshot proves the intended behavior, not just that the app launched.
## New Workspace Android Flow
Use these files when debugging or extending workspace creation on Android:
- `test-workspace-create-android-crash.sh` runs the full regression harness.
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
harness.
- `record-workspace-create-android-focus.sh` records only the focused repro
window after setup.
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
sidebar open and a prepared project visible.
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
and performs the actual workspace creation.
The reusable pieces live in `flows/`:
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
the welcome screen is visible.
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
the compact Android sidebar, and waits for the prepared project.
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
new-workspace action and waits for `/new`.
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
the app landed on the created workspace.
Compose new workspace scenarios out of these primitives instead of copying the
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
file into the same temp directory, so nested `runFlow: flows/...` paths keep
working with `${PASEO_MAESTRO_*}` placeholders.
The flow is intentionally strict. It must:
1. Open a prepared project from the daemon.
2. Tap the project row's new-workspace action.
3. Select an actual provider/model before tapping `Create`.
4. Tap `Create`.
5. Assert the app lands on a workspace header and the draft composer.
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
visible.
7. For the shell harness, grep logcat for `failed to insert view` and
`specified child already has a parent`.
Do not weaken this flow to only wait for `message-input-root`. That can pass on
the wrong route. The header assertion and the `New workspace` negative assertion
are what prove the redirect actually completed.
The scripts assume a development build with package id `sh.paseo.debug`, an
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
daemon.
```bash
bash packages/app/maestro/test-workspace-create-android-crash.sh
bash packages/app/maestro/record-workspace-create-android-focus.sh
```
Optional environment:
```bash
PASEO_MAESTRO_APP_ID=sh.paseo.debug
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
```

View File

@@ -0,0 +1,41 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Handle Expo dev launcher/dev menu screens when testing Android development
# builds. Release builds no-op through these optional/conditional steps.
- tapOn:
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
optional: true
- runFlow:
when:
visible: "DEVELOPMENT SERVERS"
commands:
# Expo dev launcher text matching is flaky on Android here; tap the first
# development server row by coordinate as the fallback.
- tapOn:
point: "50%,8%"
- waitForAnimationToEnd
- runFlow:
when:
visible: "Continue"
commands:
- tapOn: "Continue"
- tapOn:
text: "Continue"
optional: true
- waitForAnimationToEnd
- runFlow:
when:
visible: "Performance monitor"
commands:
# Close the Expo dev menu if it opens as a bottom sheet.
- tapOn:
point: "89%,9%"

View File

@@ -0,0 +1,30 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Connect to the local daemon only when the app is on the welcome screen.
- runFlow:
when:
visible:
id: "welcome-screen"
commands:
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- assertVisible:
id: "direct-host-input"
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
- tapOn:
id: "direct-host-submit"

View File

@@ -0,0 +1,17 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
# visible. Ends on the New Workspace screen.
- assertVisible:
id: "sidebar-project-list"
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
- extendedWaitUntil:
visible: "New workspace"
timeout: 30000

View File

@@ -0,0 +1,36 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Select a real provider/model on the New Workspace screen. Do not skip this
# in redirect tests: without a selected model, Create only shows validation and
# never exercises workspace creation.
- tapOn:
id: "agent-preferences-button"
- extendedWaitUntil:
visible:
id: "agent-preferences-sheet"
timeout: 10000
- tapOn:
id: "combined-model-selector"
- extendedWaitUntil:
visible: "Select model"
timeout: 30000
- tapOn: "Codex"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 30000
- tapOn: "GPT-5.4"
- tapOn: "Close"
- extendedWaitUntil:
visible: "GPT-5.4"
timeout: 10000

View File

@@ -0,0 +1,23 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start on the New Workspace screen after model selection. Taps Create and
# proves the redirect completed into a workspace draft-agent tab.
- tapOn: "Create"
- extendedWaitUntil:
visible:
id: "workspace-header-title"
timeout: 60000
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 30000
- assertNotVisible: "New workspace"
- assertNotVisible: "Select a model"
- assertNotVisible: "There was a problem loading the project."
- assertNotVisible: "The specified child already has a parent"

View File

@@ -0,0 +1,22 @@
# NOTE: Render through the workspace-create shell scripts before running when
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
appId: ${PASEO_MAESTRO_APP_ID}
---
# Start from the connected home screen and leave the Android sidebar open with
# the prepared project visible.
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- extendedWaitUntil:
visible: ${PASEO_MAESTRO_PROJECT_NAME}
timeout: 30000

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Records only the Android workspace-creation repro window.
#
# The setup Maestro flow gets the app to the open sidebar with a prepared
# project visible. Recording starts after that, then the focused flow taps the
# new-workspace button, selects a provider/model, taps Create, and asserts the
# app lands on the created workspace rather than remaining on /new.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
VIDEO_DIR="/tmp/paseo-maestro-videos"
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
render_flow_tree
echo "=== Focused Android Workspace Create Recording ==="
echo "Output dir: $OUT_DIR"
echo "Video: $LOCAL_VIDEO"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-focus-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
echo ""
echo "Staging app at open sidebar..."
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
echo ""
echo "Recording focused create flow..."
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
SCREENRECORD_PID=$!
sleep 1
set +e
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
FOCUS_STATUS=${PIPESTATUS[0]}
set -e
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
ls -lh "$LOCAL_VIDEO"
if [ "$FOCUS_STATUS" -ne 0 ]; then
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
exit "$FOCUS_STATUS"
fi
echo "Focused recording complete."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Android Maestro harness for the workspace-creation redirect crash.
#
# Starts from a clean app state, connects the Android app to the local daemon,
# opens a prepared git project, creates a workspace through the UI, and captures
# adb logcat around the redirect window.
#
# This harness is deliberately stronger than "composer is visible": it selects
# a model, taps Create, asserts the workspace header, asserts the New Workspace
# route is gone, and fails if logcat contains the Android Fabric view-parent
# crash signature.
#
# Usage:
# bash packages/app/maestro/test-workspace-create-android-crash.sh
#
# Optional environment:
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_command adb
require_command git
require_command maestro
require_command node
require_command perl
render_flow() {
local source="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
perl -0pe '
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
' "$source" > "$target"
}
render_flow_tree() {
mkdir -p "$OUT_DIR/flows"
render_flow "$FLOW_TEMPLATE" "$FLOW"
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
done
}
if [ ! -f "$SERVER_EXPORTS" ]; then
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
echo "Run: npm run build --workspace=@getpaseo/server" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
else
PROJECT_PARENT=""
fi
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
echo "=== Workspace Create Android Crash Harness ==="
echo "Output dir: $OUT_DIR"
echo "App id: $PASEO_MAESTRO_APP_ID"
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
render_flow_tree
echo "Rendered flow: $FLOW"
echo ""
echo "Preparing Android port reverse..."
adb reverse tcp:6767 tcp:6767 >/dev/null
echo ""
echo "Opening project in daemon..."
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const repoRoot = process.env.REPO_ROOT;
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
if (!repoRoot || !projectPath || !daemonUrl) {
throw new Error("Missing required environment for daemon project setup.");
}
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
const { DaemonClient } = await import(moduleUrl);
const client = new DaemonClient({
url: daemonUrl,
clientId: `maestro-workspace-create-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const payload = await client.openProject(projectPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "openProject returned no workspace");
}
console.log(
JSON.stringify({
workspaceId: payload.workspace.id,
projectId: payload.workspace.projectId,
projectDisplayName: payload.workspace.projectDisplayName,
}),
);
} finally {
await client.close().catch(() => undefined);
}
NODE
LOGCAT_PID=""
cleanup() {
if [ -n "$LOGCAT_PID" ]; then
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
echo ""
echo "Capturing Android logcat..."
adb logcat -c || true
adb logcat -v time > "$OUT_DIR/logcat.txt" &
LOGCAT_PID="$!"
echo "Running Maestro flow..."
set +e
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
MAESTRO_STATUS=${PIPESTATUS[0]}
set -e
cleanup
LOGCAT_PID=""
if [ "$MAESTRO_STATUS" -ne 0 ]; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
exit "$MAESTRO_STATUS"
fi
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
echo ""
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
exit 1
fi
echo ""
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
echo "Artifacts: $OUT_DIR"

View File

@@ -0,0 +1,26 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Android regression/reproduction flow for creating a workspace from an existing project.
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
- launchApp:
clearState: true
- runFlow: flows/android-dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- extendedWaitUntil:
visible: "What shall we build today?"
timeout: 60000
- takeScreenshot: 01-connected
- runFlow: flows/open-prepared-project-sidebar.yaml
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- takeScreenshot: 02-new-workspace
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
- takeScreenshot: 03-created-workspace

View File

@@ -0,0 +1,8 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
# already left the sidebar open with the prepared project visible.
- runFlow: flows/new-workspace-open-from-sidebar.yaml
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
- runFlow: flows/new-workspace-submit-and-assert-created.yaml

View File

@@ -0,0 +1,11 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
# Setup-only flow. It stops with the Android sidebar open and the prepared
# project visible, so focused recordings can start at the actual repro window.
- launchApp:
clearState: true
- runFlow: flows/android-dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- runFlow: flows/open-prepared-project-sidebar.yaml

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-beta.1",
"version": "0.1.60-beta.2",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -19,6 +19,7 @@
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
@@ -31,9 +32,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
"@getpaseo/highlight": "0.1.60-beta.2",
"@getpaseo/server": "0.1.60-beta.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -112,6 +113,7 @@
"@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",

View File

@@ -28,6 +28,7 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -77,7 +78,7 @@ import {
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
@@ -86,6 +87,10 @@ import {
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
@@ -124,7 +129,36 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
function PushNotificationRouter() {
const router = useRouter();
const pathname = usePathname();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId);
const workspaceId =
target.workspaceId ??
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (workspaceId) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: true,
});
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
return;
}
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
@@ -145,7 +179,7 @@ function PushNotificationRouter() {
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
router.push(buildNotificationRoute(data) as any);
openNotification(data);
},
);
@@ -165,7 +199,7 @@ function PushNotificationRouter() {
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
openNotification(customEvent.detail?.data);
};
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
@@ -198,7 +232,7 @@ function PushNotificationRouter() {
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
router.push(buildNotificationRoute(data) as any);
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
@@ -212,7 +246,7 @@ function PushNotificationRouter() {
return () => {
subscription.remove();
};
}, [router]);
}, [openNotification]);
return null;
}
@@ -319,7 +353,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
@@ -401,8 +437,6 @@ function AppContainer({
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
@@ -439,35 +473,6 @@ function AppContainer({
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = isWeb ? window.innerWidth : undefined;
const windowH = isWeb ? window.innerHeight : undefined;
const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
@@ -783,8 +788,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
useEffect(() => {
if (!activeServerId || hosts.length === 0) {
@@ -840,6 +847,7 @@ function RootStack() {
},
}}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
@@ -859,7 +867,6 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);

View File

@@ -15,6 +15,7 @@ import {
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
@@ -69,9 +70,11 @@ function clearConsumedOpenIntent(input: {
serverId: string;
workspaceId: string;
}) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl();
if (isWeb) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
stripOpenSearchParamFromBrowserUrl();
}
}
export default function HostWorkspaceLayout() {
@@ -86,6 +89,7 @@ function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
@@ -126,6 +130,9 @@ function HostWorkspaceLayoutContent() {
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
@@ -161,9 +168,17 @@ function HostWorkspaceLayoutContent() {
});
setIntentConsumed(true);
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
}, [
hasHydratedWorkspaceLayoutStore,
navigation,
openValue,
rootNavigationState?.key,
router,
serverId,
workspaceId,
]);
if (openValue && !intentConsumed) {
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
return null;
}

View File

@@ -21,6 +21,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import type { Agent } from "@/stores/session-store";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -37,6 +38,62 @@ type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
return {
serverId: agent.serverId,
id: agent.id,
provider: agent.provider,
status: agent.status,
createdAt: agent.createdAt,
updatedAt: agent.lastActivityAt,
lastUserMessageAt: null,
lastActivityAt: agent.lastActivityAt,
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: agent.provider,
sessionId: null,
},
title: agent.title,
cwd: agent.cwd,
model: null,
thinkingOptionId: null,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
attentionTimestamp: agent.attentionTimestamp,
archivedAt: agent.archivedAt,
labels: agent.labels,
};
}
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
if (!agent.archivedAt) {
return;
}
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
const existing = previous.get(agent.id);
const next = new Map(previous);
next.set(agent.id, {
...buildHistoricalAgentDetail(agent),
...existing,
archivedAt: existing?.archivedAt ?? agent.archivedAt,
cwd: existing?.cwd ?? agent.cwd,
});
return next;
});
}
function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -251,6 +308,7 @@ export function AgentList({
return;
}
rememberArchivedAgentDetail(agent);
const route = prepareWorkspaceTab({
serverId,
workspaceId,

View File

@@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
entries: snapshotEntries,
isLoading: snapshotIsLoading,
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd);
} = useProvidersSnapshot(serverId);
const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {

View File

@@ -79,6 +79,43 @@ import { isWeb } from "@/constants/platform";
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";
};
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -249,6 +286,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px
const getGapBetween = useCallback(
@@ -261,26 +299,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return tightGap;
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap;
return looseGap;
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return assistantBlockGap;
}
return looseGap;
},
[looseGap, tightGap],
[assistantBlockGap, looseGap, tightGap],
);
const renderStreamItemContent = useCallback(
@@ -335,7 +370,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
}
case "assistant_message":
case "assistant_message": {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantMessage
message={item.text}
@@ -344,8 +399,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
spacing={spacing}
/>
);
}
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -485,21 +542,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
))}
</View>
) : null;
const workingIndicatorNode = showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
) : null;
) : null,
[client, pendingPermissionItems],
);
const workingIndicatorNode = useMemo(
() =>
showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
) : null,
[showWorkingIndicator],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: {
@@ -514,7 +577,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workingIndicator: workingIndicatorNode,
},
};
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
const listEmptyComponent = useMemo(() => {
if (
@@ -579,7 +642,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
boundary.hasLiveHead
? streamRenderStrategy.getFlatListInverted()
? { paddingBottom: looseGap }
: { paddingTop: looseGap }
: null,
]}
>
{auxiliary.pendingPermissions}
@@ -587,7 +654,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]);
}, [
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
looseGap,
streamRenderStrategy,
]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
@@ -996,6 +1069,7 @@ const stylesheet = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
marginTop: theme.spacing[4],
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],

View File

@@ -9,6 +9,8 @@ import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
const {
theme,
imageMetadata,
@@ -325,7 +327,9 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
}));
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
useKeyboardActionHandler: () => {},
useKeyboardActionHandler: (input: unknown) => {
keyboardActionHandlerMock(input);
},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
@@ -575,6 +579,10 @@ beforeEach(() => {
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
vi.stubGlobal("Blob", dom.window.Blob);
Object.assign(dom.window.HTMLElement.prototype, {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
@@ -593,6 +601,7 @@ beforeEach(() => {
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear();
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
mockSessionState.sessions.server.serverInfo = {
@@ -606,6 +615,9 @@ beforeEach(() => {
},
},
};
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
@@ -719,12 +731,61 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
}
function dispatchAgentInterrupt() {
act(() => {
const registeredHandler = keyboardActionHandlerMock.mock.calls.at(-1)?.[0];
registeredHandler?.handle({ id: "agent.interrupt", scope: "global" });
});
}
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer keyboard shortcuts", () => {
it("interrupts a running agent without clearing a filled draft", async () => {
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "running", lastUsage: null }],
]);
renderComposer({ initialText: "keep this prompt" });
await flushAsyncWork();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
expect(document.querySelector('[aria-label="Message agent..."]')).toHaveProperty(
"value",
"keep this prompt",
);
});
it("interrupts a running agent when the message input is unfocused", async () => {
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "running", lastUsage: null }],
]);
renderComposer();
await flushAsyncWork();
const input = document.querySelector('[aria-label="Message agent..."]') as HTMLElement | null;
input?.blur();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
});
it("does not interrupt when the agent is idle", () => {
renderComposer();
dispatchAgentInterrupt();
expect(mockClient.cancelAgent).not.toHaveBeenCalled();
});
});
describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer();

View File

@@ -54,7 +54,7 @@ import {
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
import { markScrollInvestigationRender } from "@/utils/scroll-jank";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -176,7 +176,7 @@ export function Composer({
toastErrorRef.current = toast.error;
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({
serverId,
isConnected,
@@ -525,6 +525,15 @@ export function Composer({
}
switch (action.id) {
case "agent.interrupt":
if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) {
return true;
}
if (!isAgentRunning || isCancellingAgent || !isConnected) {
return false;
}
handleCancelAgent();
return true;
case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm":
@@ -560,12 +569,13 @@ export function Composer({
return false;
}
},
[isPaneFocused],
[handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused],
);
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
"agent.interrupt",
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
@@ -640,24 +650,12 @@ export function Composer({
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete and stop action.
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (
event.key === "Escape" &&
isAgentRunning &&
!hasSendableContent &&
!isCancellingAgent &&
isConnected
) {
event.preventDefault();
handleCancelAgent();
return true;
}
return autocompleteOnKeyPressRef.current(event);
},
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
[],
);
const cancelButton = useMemo(
@@ -683,16 +681,16 @@ export function Composer({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
{agentInterruptKeys ? (
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null,
[
agentInterruptKeys,
buttonIconSize,
dictationCancelKeys,
handleCancelAgent,
hasSendableContent,
isAgentRunning,

View File

@@ -0,0 +1,131 @@
/**
* @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 { DraggableList } from "./draggable-list.web";
type DndContextProps = {
onDragStart?: (event: { active: { id: string } }) => void;
onDragCancel?: () => void;
};
let latestDndContextProps: DndContextProps | null = null;
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
closestCenter: vi.fn(),
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
arrayMove: <T,>(items: T[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
if (item !== undefined) {
next.splice(to, 0, item);
}
return next;
},
sortableKeyboardCoordinates: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
}),
verticalListSortingStrategy: {},
}));
vi.mock("./use-web-scrollbar", () => ({
useWebScrollViewScrollbar: () => ({
onLayout: vi.fn(),
onContentSizeChange: vi.fn(),
onScroll: vi.fn(),
overlay: null,
}),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
latestDndContextProps = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function renderList(): void {
act(() => {
root?.render(
<DraggableList
data={["alpha", "beta"]}
keyExtractor={(item) => item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
)}
scrollEnabled={false}
/>,
);
});
}
function getItemActiveState(item: string): string | null {
return (
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
);
}
describe("DraggableList web", () => {
it("clears active drag state when a drag is cancelled", () => {
renderList();
act(() => {
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
});
expect(getItemActiveState("alpha")).toBe("true");
act(() => {
latestDndContextProps?.onDragCancel?.();
});
expect(getItemActiveState("alpha")).toBe("false");
});
});

View File

@@ -159,12 +159,16 @@ export function DraggableList<T>({
[data, onDragBegin],
);
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
setDragItems(null);
clearDragState();
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
@@ -176,7 +180,7 @@ export function DraggableList<T>({
}
}
},
[items, keyExtractor, onDragEnd],
[clearDragState, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -207,6 +211,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -237,6 +242,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>

View File

@@ -18,6 +18,7 @@ interface ScreenHeaderProps {
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
windowControlsPaddingRole?: "header" | "detailHeader";
onRowLayout?: (event: LayoutChangeEvent) => void;
}
@@ -31,12 +32,13 @@ export function ScreenHeader({
leftStyle,
rightStyle,
borderless,
windowControlsPaddingRole = "header",
onRowLayout,
}: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const padding = useWindowControlsPadding("header");
const padding = useWindowControlsPadding(windowControlsPaddingRole);
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];

View File

@@ -1,13 +1,15 @@
import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const storeReady = useStoreReady();
const bootstrapState = useHostRuntimeBootstrapState();
const isDesktop = shouldUseDesktopDaemon();
if (!storeReady) {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
return <>{children}</>;

View File

@@ -379,6 +379,10 @@ function MobileSidebar({
windowWidth,
]);
const handleWorkspacePress = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
const closeGesture = useMemo(
() =>
Gesture.Pan()
@@ -513,7 +517,7 @@ function MobileSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={() => closeToAgent()}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>

View File

@@ -51,10 +51,7 @@ import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
import { markScrollInvestigationEvent, markScrollInvestigationRender } from "@/utils/scroll-jank";
import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror";
@@ -307,8 +304,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (action === "dictation-cancel") {
if (isDictatingRef.current) {
cancelDictation();
return true;
}
return true;
return false;
}
if (action === "dictation-toggle") {

View File

@@ -71,7 +71,7 @@ import {
} from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
getAssistantImageMetadata,
@@ -283,6 +283,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
content: {
alignItems: "flex-end",
maxWidth: "100%",
cursor: "auto",
},
containerSpacing: {
marginBottom: theme.spacing[1],
@@ -432,6 +433,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
disableOuterSpacing?: boolean;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -439,6 +441,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
},
containerCompactTop: {
paddingTop: 0,
},
containerCompactBottom: {
paddingBottom: 0,
},
containerSpacing: {
marginBottom: theme.spacing[4],
},
@@ -951,6 +959,115 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
},
}));
interface NativeExpandableBadgeShimmerProps {
label: string;
secondaryLabel?: string;
rowWidth: number;
rowHeight: number;
peakWidth: number;
durationSeconds: number;
gradientId: string;
}
const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({
label,
secondaryLabel,
rowWidth,
rowHeight,
peakWidth,
durationSeconds,
gradientId,
}: NativeExpandableBadgeShimmerProps) {
const shimmerTranslateX = useSharedValue(0);
useEffect(() => {
const startPosition = -peakWidth;
const endPosition = rowWidth + peakWidth;
shimmerTranslateX.value = startPosition;
shimmerTranslateX.value = withRepeat(
withTiming(endPosition, {
duration: durationSeconds * 1000,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
const nativeShimmerTrackStyle = useMemo(
() => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }],
[rowHeight, rowWidth],
);
const nativeShimmerMaskStyle = useMemo(
() => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }],
[rowHeight, rowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
() => [
expandableBadgeStylesheet.nativeShimmerPeak,
nativeShimmerPeakStyle,
{ width: peakWidth, height: rowHeight },
],
[nativeShimmerPeakStyle, peakWidth, rowHeight],
);
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
</View>
}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
</Animated.View>
</View>
</MaskedView>
</View>
);
});
interface MemoizedMarkdownBlockProps {
text: string;
styles: ReturnType<typeof createMarkdownStyles>;
@@ -988,9 +1105,12 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId,
client,
disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -1191,6 +1311,10 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
(spacing === "compactTop" || spacing === "compactBoth") &&
assistantMessageStylesheet.containerCompactTop,
(spacing === "compactBottom" || spacing === "compactBoth") &&
assistantMessageStylesheet.containerCompactBottom,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
@@ -1669,7 +1793,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [labelWidth, setLabelWidth] = useState(0);
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
@@ -1737,28 +1860,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
ensureWebToolCallShimmerKeyframes();
}, [isWebShimmer]);
useEffect(() => {
if (!isNativeShimmer) {
cancelAnimation(shimmerTranslateX);
shimmerTranslateX.value = -nativeShimmerPeakWidth;
return;
}
const startPosition = -nativeShimmerPeakWidth;
const endPosition = labelRowWidth + nativeShimmerPeakWidth;
shimmerTranslateX.value = startPosition;
shimmerTranslateX.value = withRepeat(
withTiming(endPosition, {
duration: shimmerDuration * 1000,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (isNative || !isExpanded || !hasDetailContent) {
return;
@@ -1783,10 +1884,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
};
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
const shimmerGradient =
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
@@ -1886,41 +1983,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
[shimmerSecondaryStyle],
);
const nativeShimmerTrackStyle = useMemo(
() => [
expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth],
);
const nativeShimmerMaskStyle = useMemo(
() => [
expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
() => [
expandableBadgeStylesheet.nativeShimmerPeak,
nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
);
const chevronStyle = useMemo(
() => [
expandableBadgeStylesheet.chevron,
@@ -2002,52 +2064,15 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
) : null}
{isNativeShimmer ? (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
</View>
}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient
id={nativeGradientIdRef.current}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect
x="0"
y="0"
width="100%"
height="100%"
fill={`url(#${nativeGradientIdRef.current})`}
/>
</Svg>
</Animated.View>
</View>
</MaskedView>
</View>
<NativeExpandableBadgeShimmer
label={label}
secondaryLabel={secondaryLabel}
rowWidth={labelRowWidth}
rowHeight={labelRowHeight}
peakWidth={nativeShimmerPeakWidth}
durationSeconds={shimmerDuration}
gradientId={nativeGradientIdRef.current}
/>
) : null}
</View>
{isInteractive && isHovered ? (

View File

@@ -62,6 +62,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
};
function workspace(input: {

View File

@@ -0,0 +1,113 @@
/**
* @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 { SortableInlineList } from "./sortable-inline-list.web";
type DndContextProps = {
onDragStart?: (event: { active: { id: string } }) => void;
onDragCancel?: () => void;
};
let latestDndContextProps: DndContextProps | null = null;
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
closestCenter: vi.fn(),
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
arrayMove: <T,>(items: T[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
if (item !== undefined) {
next.splice(to, 0, item);
}
return next;
},
horizontalListSortingStrategy: {},
sortableKeyboardCoordinates: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
}),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
latestDndContextProps = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function renderList(): void {
act(() => {
root?.render(
<SortableInlineList
data={["alpha", "beta"]}
keyExtractor={(item) => item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
)}
/>,
);
});
}
function getItemActiveState(item: string): string | null {
return (
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
);
}
describe("SortableInlineList web", () => {
it("clears active drag state when a drag is cancelled", () => {
renderList();
act(() => {
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
});
expect(getItemActiveState("alpha")).toBe("true");
act(() => {
latestDndContextProps?.onDragCancel?.();
});
expect(getItemActiveState("alpha")).toBe("false");
});
});

View File

@@ -155,12 +155,16 @@ export function SortableInlineList<T>({
[data, disabled, onDragBegin],
);
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
setDragItems(null);
clearDragState();
if (disabled) {
return;
@@ -176,7 +180,7 @@ export function SortableInlineList<T>({
}
}
},
[disabled, items, keyExtractor, onDragEnd],
[clearDragState, disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -213,6 +217,7 @@ export function SortableInlineList<T>({
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
{renderedItems}

View File

@@ -8,6 +8,7 @@ import {
type NativeSyntheticEvent,
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import {
@@ -37,6 +38,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle,
strategy,
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized",
@@ -190,105 +192,91 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
viewportHeight: Math.max(0, layoutMeasurement.height),
containerKey: "native-virtualized",
offsetY: contentOffset.y,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
};
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
viewportHeight: Math.max(0, layoutMeasurement.height),
containerKey: "native-virtualized",
offsetY: contentOffset.y,
viewportMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
onNearBottomChange(nearBottom);
}
});
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
},
[bottomAnchorController, onNearBottomChange, strategy],
);
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
viewportWidth,
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
});
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
viewportWidth,
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
},
[bottomAnchorController, markNativeViewportSettling],
);
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
});
const handleContentSizeChange = useCallback(
(_width: number, height: number) => {
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: "native-virtualized",
contentHeight: nextContentHeight,
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
},
[bottomAnchorController],
);
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
},
[historyRows, renderers],
);
const renderItem = useStableEvent(({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
});
const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
const liveAuxiliary = renderers.renderLiveAuxiliary();
const liveAuxiliary = renderLiveAuxiliary();
if (
liveHeadRows.length === 0 &&
!liveAuxiliary &&
@@ -303,7 +291,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
{liveAuxiliary}
</Fragment>
);
}, [boundary, listEmptyComponent, renderers, segments.liveHead]);
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
return (
<FlatList
@@ -321,8 +309,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={12}
windowSize={10}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
windowSize={21}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
inverted

View File

@@ -8,6 +8,7 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
computeScrollOffsetFromDragDelta,
@@ -325,6 +326,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOpenExternalUrl: openExternalUrl,
},
});
runtime.setPendingModifiers({ pendingModifiers });
@@ -350,6 +352,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOpenExternalUrl: openExternalUrl,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);

View File

@@ -5,12 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
@@ -94,39 +89,6 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: {
color: theme.colors.accentForeground,
},
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
setupLink: {
flexDirection: "row",
alignItems: "center",
@@ -189,50 +151,6 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
@@ -306,7 +224,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
return (
<View style={styles.root}>
@@ -322,11 +240,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
{showHostList ? (
<Text style={styles.subtitle}>Connecting to your hosts</Text>
{isConnectingToSavedHosts ? (
<Text style={styles.subtitle}>Connecting</Text>
) : (
<>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
@@ -365,14 +283,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -132,7 +132,8 @@ function WorkspaceHoverCardDesktop({
const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false;
}, []);
scheduleClose();
}, [scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering
@@ -155,11 +156,16 @@ function WorkspaceHoverCardDesktop({
// When content becomes available while trigger is already hovered, open the card.
useEffect(() => {
if (!hasContent || isDragging) return;
if (!hasContent) {
clearGraceTimer();
setOpen(false);
return;
}
if (isDragging) return;
if (triggerHoveredRef.current) {
setOpen(true);
}
}, [hasContent, isDragging]);
}, [clearGraceTimer, hasContent, isDragging]);
// Cleanup on unmount
useEffect(() => {

View File

@@ -32,6 +32,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
};
describe("patchWorkspaceScripts", () => {

View File

@@ -8,8 +8,8 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
createSessionAgentStreamReducerQueue,
processTimelineResponse,
processAgentStreamEvent,
} from "@/contexts/session-stream-reducers";
import type {
ActivityLogPayload,
@@ -1049,6 +1049,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyAgentUpdatePayload(update);
});
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
requestCanonicalCatchUp,
});
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
@@ -1075,96 +1083,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}
// Read current store state
const session = useSessionStore.getState().sessions[serverId];
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
const currentCursor = session?.agentTimelineCursor.get(agentId);
const currentAgentEntry = session?.agents.get(agentId);
const currentAgent = currentAgentEntry
? {
status: currentAgentEntry.status,
updatedAt: currentAgentEntry.updatedAt,
lastActivityAt: currentAgentEntry.lastActivityAt,
}
: null;
// Call pure reducer
const result = processAgentStreamEvent({
agentStreamReducerQueue.enqueue(agentId, {
event: streamEvent,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
currentAgent,
timestamp: parsedTimestamp,
});
// Apply tail/head patches
if (result.changedTail || result.changedHead) {
setAgentStreamState(serverId, agentId, {
...(result.changedTail ? { tail: result.tail } : {}),
...(result.changedHead ? { head: result.head } : {}),
});
}
// Apply cursor patch
if (result.cursorChanged && result.cursor) {
const nextCursor = result.cursor;
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
if (
current &&
typeof seq === "number" &&
typeof epoch === "string" &&
current.epoch === epoch &&
seq >= current.startSeq &&
seq <= current.endSeq
) {
// Fast-path: seq stays inside the current range during streaming.
return prev;
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
return prev;
}
const next = new Map(prev);
next.set(agentId, nextCursor);
return next;
});
}
// Apply agent patch (optimistic lifecycle)
if (result.agentChanged && result.agent) {
const nextAgent = result.agent;
setAgents(serverId, (prev) => {
const current = prev.get(agentId);
if (!current) {
return prev;
}
const next = new Map(prev);
next.set(agentId, {
...current,
status: nextAgent.status,
updatedAt: nextAgent.updatedAt,
lastActivityAt: nextAgent.lastActivityAt,
});
return next;
});
}
// Execute side effects
for (const effect of result.sideEffects) {
if (effect.type === "catch_up") {
requestCanonicalCatchUp(agentId, effect.cursor);
}
}
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes.
@@ -1172,6 +1097,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
if (message.type !== "fetch_agent_timeline_response") return;
agentStreamReducerQueue.flushAgent(message.payload.agentId);
applyTimelineResponse(message.payload);
});
@@ -1572,6 +1498,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubVoiceInputState();
unsubAgentDeleted();
unsubAgentArchived();
agentStreamReducerQueue.dispose({ flush: true });
};
}, [
client,

View File

@@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem } from "@/types/stream";
import {
createAgentStreamReducerQueue,
processTimelineResponse,
processAgentStreamEvent,
processAgentStreamEvents,
type ProcessTimelineResponseInput,
type ProcessAgentStreamEventInput,
type AgentStreamReducerEvent,
type TimelineCursor,
} from "./session-stream-reducers";
@@ -41,6 +44,36 @@ function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
} as AgentStreamEventPayload;
}
function makeToolCallTimelineEvent(callId: string): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId,
name: "Read",
status: "running",
detail: {
type: "read",
filePath: "/tmp/example.ts",
},
error: null,
},
} as AgentStreamEventPayload;
}
function makeStreamReducerEvent(
event: AgentStreamEventPayload,
seq: number,
): AgentStreamReducerEvent {
return {
event,
seq,
epoch: "epoch-1",
timestamp: new Date(1000 + seq),
};
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
@@ -647,3 +680,270 @@ describe("processAgentStreamEvent", () => {
expect(result.agent).toBe(null);
});
});
describe("processAgentStreamEvents", () => {
it("coalesces contiguous assistant stream events into one head update and final cursor", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Hello"), 1),
makeStreamReducerEvent(makeTimelineEvent(" world"), 2),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(true);
expect(result.tail).toEqual([]);
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "Hello world",
});
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 2,
} satisfies TimelineCursor);
expect(result.sideEffects).toEqual([]);
});
it("promotes completed assistant markdown blocks to tail while keeping the live block in head", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("First paragraph"), 1),
makeStreamReducerEvent(makeTimelineEvent("\n\nSecond paragraph"), 2),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
expect(result.changedHead).toBe(true);
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({
kind: "assistant_message",
text: "First paragraph",
});
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "Second paragraph",
});
});
it("does not promote a markdown block that is still inside an open code fence", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Before fence\n\n```ts\nconst a = 1;"), 1),
makeStreamReducerEvent(makeTimelineEvent("\n\nconst b = 2;"), 2),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({
kind: "assistant_message",
text: "Before fence",
});
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "```ts\nconst a = 1;\n\nconst b = 2;",
});
});
it("flushes the live assistant block before applying a tool call in the same reducer pass", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Before tool"), 1),
makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 2),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
expect(result.changedHead).toBe(true);
expect(result.head).toEqual([]);
expect(result.tail.map((item: StreamItem) => item.kind)).toEqual([
"assistant_message",
"tool_call",
]);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 2,
} satisfies TimelineCursor);
});
it("returns the final optimistic lifecycle patch across a batch", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Done"), 1),
{
event: { type: "turn_completed", provider: "claude" } as AgentStreamEventPayload,
seq: undefined,
epoch: undefined,
timestamp: new Date(3000),
},
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
});
expect(result.head).toEqual([]);
expect(result.tail).toHaveLength(1);
expect(result.agentChanged).toBe(true);
expect(result.agent).toMatchObject({
status: "idle",
updatedAt: new Date(3000),
lastActivityAt: new Date(3000),
});
});
});
describe("createAgentStreamReducerQueue", () => {
function createManualScheduler() {
let nextId = 1;
const callbacks = new Map<number, () => void>();
return {
schedule(callback: () => void) {
const id = nextId;
nextId += 1;
callbacks.set(id, callback);
return id;
},
cancel(id: number) {
callbacks.delete(id);
},
flushOne() {
const entry = callbacks.entries().next().value;
if (!entry) {
throw new Error("Expected a scheduled callback");
}
const [id, callback] = entry;
callbacks.delete(id);
callback();
},
get size() {
return callbacks.size;
},
};
}
it("coalesces multiple events for one agent into one scheduled commit", () => {
const scheduler = createManualScheduler();
const commits: Array<{ agentId: string; headText: string; cursorEndSeq: number | null }> = [];
let currentTail: StreamItem[] = [];
let currentHead: StreamItem[] = [];
const queue = createAgentStreamReducerQueue({
getSnapshot: () => ({
currentTail,
currentHead,
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
currentTail = result.tail;
currentHead = result.head;
commits.push({
agentId,
headText: result.head[0]?.kind === "assistant_message" ? result.head[0].text : "",
cursorEndSeq: result.cursor?.endSeq ?? null,
});
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("Hello"), 1));
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent(" world"), 2));
expect(scheduler.size).toBe(1);
expect(commits).toEqual([]);
scheduler.flushOne();
expect(commits).toEqual([
{
agentId: "agent-1",
headText: "Hello world",
cursorEndSeq: 2,
},
]);
expect(scheduler.size).toBe(0);
});
it("flushes queued events synchronously for one agent before canonical history is applied", () => {
const scheduler = createManualScheduler();
const commits: string[] = [];
const queue = createAgentStreamReducerQueue({
getSnapshot: () => ({
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
);
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("queued"), 1));
queue.flushAgent("agent-1");
expect(commits).toEqual(["agent-1:queued"]);
expect(scheduler.size).toBe(0);
});
it("flushes queued events synchronously before disposal", () => {
const scheduler = createManualScheduler();
const commits: string[] = [];
const queue = createAgentStreamReducerQueue({
getSnapshot: () => ({
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
);
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
queue.enqueue("agent-1", makeStreamReducerEvent(makeTimelineEvent("queued"), 1));
queue.dispose({ flush: true });
expect(commits).toEqual(["agent-1:queued"]);
expect(scheduler.size).toBe(0);
});
});

View File

@@ -1,5 +1,7 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import {
applyStreamEvent,
@@ -17,6 +19,8 @@ import {
} from "@/contexts/session-timeline-bootstrap-policy";
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
// ---------------------------------------------------------------------------
// Shared cursor type
// ---------------------------------------------------------------------------
@@ -324,6 +328,62 @@ export interface ProcessAgentStreamEventOutput {
sideEffects: AgentStreamReducerSideEffect[];
}
export type AgentStreamReducerEvent = {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
timestamp: Date;
};
export type AgentStreamReducerAgentSnapshot = {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
};
export type ProcessAgentStreamEventsInput = {
events: AgentStreamReducerEvent[];
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
currentAgent: AgentStreamReducerAgentSnapshot | null;
};
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
export type AgentStreamReducerQueue = {
enqueue: (agentId: string, event: AgentStreamReducerEvent) => void;
flush: () => void;
flushAgent: (agentId: string) => void;
dispose: (options?: { flush?: boolean }) => void;
};
export type CreateAgentStreamReducerQueueInput = {
getSnapshot: (agentId: string) => AgentStreamReducerSnapshot;
commit: (
agentId: string,
result: ProcessAgentStreamEventOutput,
events: AgentStreamReducerEvent[],
) => void;
handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void;
scheduleFlush: (callback: () => void) => number;
cancelFlush: (id: number) => void;
};
function applyAgentPatch(
currentAgent: AgentStreamReducerAgentSnapshot | null,
patch: AgentPatch | null,
): AgentStreamReducerAgentSnapshot | null {
if (!currentAgent || !patch) {
return currentAgent;
}
return {
status: patch.status,
updatedAt: patch.updatedAt,
lastActivityAt: patch.lastActivityAt,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
@@ -430,3 +490,255 @@ export function processAgentStreamEvent(
sideEffects,
};
}
export function processAgentStreamEvents(
input: ProcessAgentStreamEventsInput,
): ProcessAgentStreamEventOutput {
let tail = input.currentTail;
let head = input.currentHead;
let cursor = input.currentCursor;
let agent = input.currentAgent;
let changedTail = false;
let changedHead = false;
let cursorChanged = false;
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
for (const reducerEvent of input.events) {
const result = processAgentStreamEvent({
event: reducerEvent.event,
seq: reducerEvent.seq,
epoch: reducerEvent.epoch,
currentTail: tail,
currentHead: head,
currentCursor: cursor,
currentAgent: agent,
timestamp: reducerEvent.timestamp,
});
tail = result.tail;
head = result.head;
changedTail = changedTail || result.changedTail;
changedHead = changedHead || result.changedHead;
sideEffects.push(...result.sideEffects);
if (result.cursorChanged) {
cursor = result.cursor ?? undefined;
cursorChanged = true;
}
if (result.agentChanged) {
agentPatch = result.agent;
agentChanged = true;
agent = applyAgentPatch(agent, result.agent);
}
}
return {
tail,
head,
changedTail,
changedHead,
cursor: cursor ?? null,
cursorChanged,
agent: agentPatch,
agentChanged,
sideEffects,
};
}
export function createAgentStreamReducerQueue(
input: CreateAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const pendingByAgentId = new Map<string, AgentStreamReducerEvent[]>();
let scheduledFlushId: number | null = null;
const cancelScheduledFlush = () => {
if (scheduledFlushId === null) {
return;
}
input.cancelFlush(scheduledFlushId);
scheduledFlushId = null;
};
const flushAgent = (agentId: string) => {
const events = pendingByAgentId.get(agentId);
if (!events || events.length === 0) {
return;
}
pendingByAgentId.delete(agentId);
if (pendingByAgentId.size === 0) {
cancelScheduledFlush();
}
const result = processAgentStreamEvents({
events,
...input.getSnapshot(agentId),
});
input.commit(agentId, result, events);
if (result.sideEffects.length > 0) {
input.handleSideEffects(agentId, result.sideEffects);
}
};
const flush = () => {
const agentIds = Array.from(pendingByAgentId.keys());
for (const agentId of agentIds) {
flushAgent(agentId);
}
};
const scheduleFlush = () => {
if (scheduledFlushId !== null) {
return;
}
scheduledFlushId = input.scheduleFlush(() => {
scheduledFlushId = null;
flush();
});
};
return {
enqueue(agentId, event) {
const pending = pendingByAgentId.get(agentId);
if (pending) {
pending.push(event);
} else {
pendingByAgentId.set(agentId, [event]);
}
scheduleFlush();
},
flush,
flushAgent,
dispose(options) {
cancelScheduledFlush();
if (options?.flush) {
flush();
} else {
pendingByAgentId.clear();
}
},
};
}
type StreamStatePatch = {
tail?: StreamItem[];
head?: StreamItem[];
};
export type CreateSessionAgentStreamReducerQueueInput = {
serverId: string;
setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void;
setAgentTimelineCursor: (
serverId: string,
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
) => void;
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
};
function scheduleAgentStreamReducerFlush(callback: () => void): number {
return setTimeout(callback, AGENT_STREAM_REDUCER_FLUSH_DELAY_MS) as unknown as number;
}
function cancelAgentStreamReducerFlush(id: number) {
clearTimeout(id);
}
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const {
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
requestCanonicalCatchUp,
} = input;
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const currentAgentEntry = session?.agents.get(agentId);
return {
currentTail: session?.agentStreamTail.get(agentId) ?? [],
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor: session?.agentTimelineCursor.get(agentId),
currentAgent: currentAgentEntry
? {
status: currentAgentEntry.status,
updatedAt: currentAgentEntry.updatedAt,
lastActivityAt: currentAgentEntry.lastActivityAt,
}
: null,
};
},
commit: (agentId, result, events) => {
if (result.changedTail || result.changedHead) {
setAgentStreamState(serverId, agentId, {
...(result.changedTail ? { tail: result.tail } : {}),
...(result.changedHead ? { head: result.head } : {}),
});
}
if (result.cursorChanged && result.cursor) {
const nextCursor = result.cursor;
const lastEvent = events.at(-1);
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
if (
current &&
lastEvent &&
typeof lastEvent.seq === "number" &&
typeof lastEvent.epoch === "string" &&
current.epoch === lastEvent.epoch &&
lastEvent.seq >= current.startSeq &&
lastEvent.seq <= current.endSeq
) {
return prev;
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
return prev;
}
const next = new Map(prev);
next.set(agentId, nextCursor);
return next;
});
}
if (result.agentChanged && result.agent) {
const nextAgent = result.agent;
setAgents(serverId, (prev) => {
const current = prev.get(agentId);
if (!current) {
return prev;
}
const next = new Map(prev);
next.set(agentId, {
...current,
status: nextAgent.status,
updatedAt: nextAgent.updatedAt,
lastActivityAt: nextAgent.lastActivityAt,
});
return next;
});
}
},
handleSideEffects: (agentId, sideEffects) => {
for (const effect of sideEffects) {
if (effect.type === "catch_up") {
requestCanonicalCatchUp(agentId, effect.cursor);
}
}
},
scheduleFlush: scheduleAgentStreamReducerFlush,
cancelFlush: cancelAgentStreamReducerFlush,
});
}

View File

@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
error: snapshotError,
refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(formState.serverId, formState.workingDir);
} = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(

View File

@@ -0,0 +1,262 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
FetchAgentHistoryOptions,
} from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useAgentHistory } from "./use-agent-history";
const { mockClient, mockRuntimeStore } = vi.hoisted(() => {
const mockClient = {
fetchAgentHistory: vi.fn(),
};
const mockRuntimeStore = {
refreshAgentDirectory: vi.fn(),
};
return { mockClient, mockRuntimeStore };
});
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => mockRuntimeStore,
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
useHosts: () => [{ serverId: "server-1", label: "Local" }],
}));
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function renderAgentHistoryHook(options?: { enabled?: boolean }) {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), {
wrapper,
});
}
function makeHistoryPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
nextCursor?: string | null;
}): Awaited<ReturnType<DaemonClient["fetchAgentHistory"]>> {
return {
requestId: "req_history",
entries: input.entries,
pageInfo: {
nextCursor: input.nextCursor ?? null,
prevCursor: null,
hasMore: input.hasMore ?? false,
},
};
}
function makeHistoryEntry(input: {
id: string;
cwd: string;
updatedAt: string;
title?: string | null;
archivedAt?: string | null;
}): FetchAgentHistoryEntry {
return {
agent: {
id: input.id,
provider: "codex",
status: "closed",
createdAt: input.updatedAt,
updatedAt: input.updatedAt,
lastUserMessageAt: null,
lastError: undefined,
runtimeInfo: {
provider: "codex",
sessionId: null,
},
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: input.title ?? null,
cwd: input.cwd,
model: null,
thinkingOptionId: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: input.archivedAt ?? null,
labels: {},
},
project: {
projectKey: input.cwd,
projectName: "workspace",
checkout: {
cwd: input.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function makeActiveAgent(): Agent {
const timestamp = new Date("2026-04-01T10:00:00.000Z");
return {
serverId: "server-1",
id: "active-1",
provider: "codex",
status: "idle",
createdAt: timestamp,
updatedAt: timestamp,
lastUserMessageAt: null,
lastActivityAt: timestamp,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Active",
cwd: "/repo",
model: null,
labels: {},
archivedAt: null,
};
}
afterEach(() => {
mockClient.fetchAgentHistory.mockReset();
mockRuntimeStore.refreshAgentDirectory.mockReset();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("useAgentHistory", () => {
it("loads history one page at a time without refreshing active agents", async () => {
mockClient.fetchAgentHistory
.mockResolvedValueOnce(
makeHistoryPayload({
entries: [
makeHistoryEntry({
id: "history-1",
cwd: "/repo",
updatedAt: "2026-04-02T10:00:00.000Z",
title: "History one",
}),
],
hasMore: true,
nextCursor: "cursor-2",
}),
)
.mockResolvedValueOnce(
makeHistoryPayload({
entries: [
makeHistoryEntry({
id: "history-2",
cwd: "/repo",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "History two",
archivedAt: "2026-04-01T10:05:00.000Z",
}),
],
}),
);
act(() => {
useSessionStore
.getState()
.initializeSession("server-1", mockClient as unknown as DaemonClient);
useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]]));
});
const { result } = renderAgentHistoryHook();
await waitFor(() => {
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
});
expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([
{
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200 },
},
] satisfies FetchAgentHistoryOptions[]);
await waitFor(() => {
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1"]);
});
expect(result.current.hasMore).toBe(true);
await act(async () => {
result.current.loadMore();
});
await waitFor(() => {
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2);
});
expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-2" },
} satisfies FetchAgentHistoryOptions);
await waitFor(() => {
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1", "history-2"]);
});
expect(result.current.hasMore).toBe(false);
expect(
Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []),
).toEqual(["active-1"]);
expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled();
});
it("waits until history is enabled before calling the history RPC", async () => {
mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] }));
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
let enabled = false;
const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), {
wrapper,
});
await act(async () => {
await Promise.resolve();
});
expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled();
enabled = true;
rerender();
await waitFor(() => {
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,155 @@
import type {
DaemonClient,
FetchAgentHistoryOptions,
FetchAgentHistoryPageInfo,
} from "@server/client/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
const AGENT_HISTORY_PAGE_LIMIT = 200;
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
export interface AgentHistoryResult {
agents: AggregatedAgent[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
hasMore: boolean;
isLoadingMore: boolean;
refreshAll: () => void;
loadMore: () => void;
}
type AgentHistoryPage = {
agents: AggregatedAgent[];
pageInfo: FetchAgentHistoryPageInfo;
};
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
async function fetchAgentHistoryPage(input: {
client: DaemonClient;
serverId: string;
cursor: string | null;
}): Promise<AgentHistoryPage> {
const payload = await input.client.fetchAgentHistory({
sort: AGENT_HISTORY_SORT,
page: input.cursor
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
: { limit: AGENT_HISTORY_PAGE_LIMIT },
});
const { agents } = buildAgentDirectoryState({
serverId: input.serverId,
entries: payload.entries,
});
return {
agents: Array.from(agents.values(), (agent) => ({
id: agent.id,
serverId: input.serverId,
serverLabel: input.serverId,
title: agent.title ?? null,
status: agent.status,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
provider: agent.provider,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
attentionTimestamp: agent.attentionTimestamp ?? null,
archivedAt: agent.archivedAt ?? null,
createdAt: agent.createdAt,
labels: agent.labels,
})),
pageInfo: payload.pageInfo,
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
}): AgentHistoryResult {
const daemons = useHosts();
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const historyQuery = useInfiniteQuery<
AgentHistoryPage,
Error,
{ pages: AgentHistoryPage[] },
ReturnType<typeof agentHistoryQueryKey>,
string | null
>({
queryKey,
enabled: Boolean(enabled && serverId && client && isConnected),
staleTime: 30_000,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
queryFn: async ({ pageParam }) => {
if (!serverId || !client) {
throw new Error("Host is not connected");
}
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
},
});
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
historyQuery;
const refreshAll = useCallback(() => {
if (!serverId || !client || !isConnected) {
return;
}
void refetch();
}, [client, isConnected, refetch, serverId]);
const loadMore = useCallback(() => {
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
return;
}
void fetchNextPage();
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
const agents = useMemo(
() =>
(data?.pages ?? [])
.flatMap((page) => page.agents)
.map((agent) => ({
...agent,
serverLabel: serverLabel ?? agent.serverLabel,
})),
[data?.pages, serverLabel],
);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
return {
agents,
isLoading,
isInitialLoad,
isRevalidating,
hasMore: Boolean(hasNextPage),
isLoadingMore: isFetchingNextPage,
refreshAll,
loadMore,
};
}
export const __private__ = {
fetchAgentHistoryPage,
};

View File

@@ -49,7 +49,10 @@ vi.mock("@server/client/daemon-client", () => ({}));
function createTestClient() {
return {
isConnected: true,
subscribeConnectionStatus: vi.fn(() => vi.fn()),
subscribeConnectionStatus: vi.fn((listener: (state: { status: "connected" }) => void) => {
listener({ status: "connected" });
return vi.fn();
}),
sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(),
};
}
@@ -68,13 +71,15 @@ function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
async function renderActivityHook({
client = createTestClient(),
focusedAgentId = "agent-1",
}: {
client?: ReturnType<typeof createTestClient>;
focusedAgentId?: string | null;
} = {}) {
function Probe() {
function Probe({ focusedAgentId }: { focusedAgentId: string | null }) {
useClientActivity({
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
focusedAgentId: "agent-1",
focusedAgentId,
});
return null;
}
@@ -84,10 +89,16 @@ async function renderActivityHook({
const root = createRoot(container);
await act(async () => {
root.render(<Probe />);
root.render(<Probe focusedAgentId={focusedAgentId} />);
});
return { client, root };
const rerender = async (nextFocusedAgentId: string | null) => {
await act(async () => {
root.render(<Probe focusedAgentId={nextFocusedAgentId} />);
});
};
return { client, rerender, root };
}
async function advance(ms: number) {
@@ -138,6 +149,24 @@ describe("useClientActivity", () => {
expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled();
});
it("sends one immediate heartbeat when the focused agent changes", async () => {
const rendered = await renderActivityHook();
root = rendered.root;
expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1);
rendered.client.sendHeartbeat.mockClear();
vi.setSystemTime(new Date("2026-04-19T10:00:05.000Z"));
await rendered.rerender("agent-2");
expect(rendered.client.subscribeConnectionStatus).toHaveBeenCalledTimes(1);
expect(rendered.client.sendHeartbeat).toHaveBeenCalledTimes(1);
expect(latestHeartbeat(rendered.client)).toMatchObject({
focusedAgentId: "agent-2",
lastActivityAt: "2026-04-19T10:00:05.000Z",
});
});
it("drives lastActivityAt forward from Electron idle polling", async () => {
platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(0);

View File

@@ -3,6 +3,7 @@ import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import { getIsElectron, isWeb, isNative } from "@/constants/platform";
import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle";
import { useStableEvent } from "@/hooks/use-stable-event";
const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -41,7 +42,7 @@ export function useClientActivity({
lastActivityAtRef.current = new Date();
}, []);
const sendHeartbeat = useCallback(() => {
const sendHeartbeat = useStableEvent(() => {
if (!client.isConnected) return;
client.sendHeartbeat({
deviceType,
@@ -50,7 +51,7 @@ export function useClientActivity({
appVisible: appVisibleRef.current,
appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(),
});
}, [client, deviceType, focusedAgentId]);
});
const setAppVisible = useCallback(
(nextVisible: boolean) => {
@@ -191,10 +192,6 @@ export function useClientActivity({
}
});
if (client.isConnected) {
startHeartbeat();
}
return () => {
unsubscribe();
stopHeartbeat();

View File

@@ -0,0 +1,141 @@
/**
* @vitest-environment jsdom
*/
import React, { useRef, type RefObject } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useHoverSafeZone } from "./use-hover-safe-zone";
vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
type RectInput = {
left: number;
right: number;
top: number;
bottom: number;
};
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function installRect(node: HTMLDivElement | null, rect: RectInput): void {
if (!node) return;
node.getBoundingClientRect = () =>
({
...rect,
x: rect.left,
y: rect.top,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
toJSON: () => rect,
}) as DOMRect;
}
function Harness({
onEnterSafeZone,
onLeaveSafeZone,
}: {
onEnterSafeZone: () => void;
onLeaveSafeZone: () => void;
}) {
const triggerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
useHoverSafeZone({
enabled: true,
triggerRef: triggerRef as unknown as RefObject<View | null>,
contentRef: contentRef as unknown as RefObject<View | null>,
onEnterSafeZone,
onLeaveSafeZone,
});
return (
<>
<div
ref={(node) => {
triggerRef.current = node;
installRect(node, { left: 0, right: 100, top: 20, bottom: 60 });
}}
/>
<div
ref={(node) => {
contentRef.current = node;
installRect(node, { left: 120, right: 240, top: 20, bottom: 120 });
}}
/>
</>
);
}
function pointerMove(x: number, y: number): void {
document.dispatchEvent(
new window.MouseEvent("pointermove", {
bubbles: true,
clientX: x,
clientY: y,
}),
);
}
describe("useHoverSafeZone", () => {
it("tracks transitions across trigger, bridge, content, and outside space", () => {
const onEnterSafeZone = vi.fn();
const onLeaveSafeZone = vi.fn();
act(() => {
root?.render(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => pointerMove(110, 40));
expect(onLeaveSafeZone).not.toHaveBeenCalled();
act(() => pointerMove(300, 40));
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => pointerMove(130, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(1);
});
it("treats leaving the browser window as leaving the safe zone", () => {
const onEnterSafeZone = vi.fn();
const onLeaveSafeZone = vi.fn();
act(() => {
root?.render(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => {
window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true }));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new window.FocusEvent("blur"));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
});
});

View File

@@ -35,6 +35,18 @@ export function useHoverSafeZone({
// The pointer opened the card, so we start inside.
let wasInside = true;
function enterSafeZone() {
if (wasInside) return;
wasInside = true;
onEnterSafeZone();
}
function leaveSafeZone() {
if (!wasInside) return;
wasInside = false;
onLeaveSafeZone();
}
function handlePointerMove(event: PointerEvent) {
const triggerNode = triggerRef.current as unknown as Element | null;
const contentNode = contentRef.current as unknown as Element | null;
@@ -42,15 +54,23 @@ export function useHoverSafeZone({
const contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
if (inside === wasInside) return;
wasInside = inside;
if (inside) onEnterSafeZone();
else onLeaveSafeZone();
if (inside) enterSafeZone();
else leaveSafeZone();
}
function handlePointerOut(event: PointerEvent) {
if (event.relatedTarget === null) {
leaveSafeZone();
}
}
document.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerout", handlePointerOut);
window.addEventListener("blur", leaveSafeZone);
return () => {
document.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerout", handlePointerOut);
window.removeEventListener("blur", leaveSafeZone);
};
}, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]);
}

View File

@@ -11,6 +11,7 @@ const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
routerMock: {
back: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
},
pathState: {
pathname: "/h/srv/workspace/ws-2",
@@ -56,6 +57,7 @@ vi.mock("@/hooks/use-workspace-navigation", () => ({
}));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
activateNavigationWorkspaceSelection,
@@ -80,6 +82,7 @@ describe("useKeyboardShortcuts", () => {
navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset();
routerMock.push.mockReset();
routerMock.replace.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({
@@ -132,4 +135,57 @@ describe("useKeyboardShortcuts", () => {
});
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);
});
});

View File

@@ -4,6 +4,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -27,7 +28,10 @@ import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({
enabled,
@@ -159,6 +163,11 @@ export function useKeyboardShortcuts({
event: KeyboardEvent;
}): boolean => {
switch (input.action) {
case "agent.interrupt":
return keyboardActionDispatcher.dispatch({
id: "agent.interrupt",
scope: "global",
});
case "agent.new":
return openProjectPicker();
case "workspace.tab.new":
@@ -234,6 +243,18 @@ export function useKeyboardShortcuts({
return true;
case "settings.toggle":
if (pathname.startsWith("/settings")) {
if (!isMobile) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(
lastWorkspaceRoute.serverId,
lastWorkspaceRoute.workspaceId,
),
);
return true;
}
}
router.back();
return true;
}

View File

@@ -9,11 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useSessionStore } from "@/stores/session-store";
import {
providersSnapshotQueryKey,
shouldApplyProvidersSnapshotUpdate,
useProvidersSnapshot,
} from "./use-providers-snapshot";
import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot";
type ProviderSnapshotUpdateMessage = {
type: "providers_snapshot_update";
@@ -80,12 +76,12 @@ function enableProvidersSnapshot(): void {
});
}
function renderProvidersSnapshotHook(cwd?: string | null) {
function renderProvidersSnapshotHook() {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
return renderHook(() => useProvidersSnapshot(serverId), { wrapper });
}
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
@@ -124,7 +120,10 @@ async function waitForSnapshotEntries(
});
}
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
async function emitProvidersSnapshotUpdate(
entries: ProviderSnapshotEntry[],
cwd = "/repo",
): Promise<void> {
const listener = snapshotUpdateListeners.at(-1);
expect(listener).toBeDefined();
@@ -132,7 +131,7 @@ async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Pr
listener?.({
type: "providers_snapshot_update",
payload: {
cwd: "/repo",
cwd,
entries,
generatedAt: "2026-01-01T00:00:01.000Z",
},
@@ -155,23 +154,8 @@ afterEach(() => {
});
describe("providers snapshot hook cache scope", () => {
it("uses no cwd in the settings query key", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
});
it("accepts concrete home update events for settings snapshots", () => {
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
});
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
true,
);
expect(
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
).toBe(false);
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
it("uses a global query key without cwd", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
});
it("sends no cwd for settings snapshot loads and refreshes", async () => {
@@ -196,7 +180,7 @@ describe("providers snapshot hook cache scope", () => {
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
});
it("sends cwd for workspace snapshot loads and refreshes", async () => {
it("does not send cwd for repeated snapshot loads and refreshes", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({
@@ -204,21 +188,30 @@ describe("providers snapshot hook cache scope", () => {
requestId: "workspace-refresh",
});
const { result } = renderProvidersSnapshotHook("/repo");
const { result } = renderProvidersSnapshotHook();
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
});
await act(async () => {
await result.current.refresh(["codex"]);
});
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
cwd: "/repo",
providers: ["codex"],
});
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
});
it("applies provider snapshot updates from other cwd values to the global cache", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
const { result } = renderProvidersSnapshotHook();
await waitForSnapshotEntries(result, []);
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b");
await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]);
});
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
@@ -227,7 +220,7 @@ describe("providers snapshot hook cache scope", () => {
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
renderProvidersSnapshotHook("/repo");
renderProvidersSnapshotHook();
await waitForSnapshotReads(1);
await emitProvidersSnapshotUpdate([]);
@@ -237,7 +230,7 @@ describe("providers snapshot hook cache scope", () => {
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
@@ -252,13 +245,13 @@ describe("providers snapshot hook cache scope", () => {
.mockResolvedValueOnce(providersSnapshot(entries))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
const { result } = renderProvidersSnapshotHook("/repo");
const { result } = renderProvidersSnapshotHook();
await waitForSnapshotEntries(result, entries);
await openSelectorForSelectedProvider(result);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
@@ -266,7 +259,7 @@ describe("providers snapshot hook cache scope", () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
const { result } = renderProvidersSnapshotHook("/repo");
const { result } = renderProvidersSnapshotHook();
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
await openSelectorForSelectedProvider(result);

View File

@@ -6,40 +6,8 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client";
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
const trimmed = cwd?.trim();
if (!trimmed) {
return null;
}
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
}
export function shouldApplyProvidersSnapshotUpdate(
currentCwd?: string | null,
messageCwd?: string | null,
): boolean {
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
}
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
}
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
return cwd ? { cwd } : {};
}
function refreshProvidersSnapshotRequest(
cwd: string | undefined,
providers: AgentProvider[] | undefined,
): { cwd?: string; providers?: AgentProvider[] } {
return {
...providersSnapshotRequest(cwd),
...(providers ? { providers } : {}),
};
export function providersSnapshotQueryKey(serverId: string | null) {
return ["providersSnapshot", serverId] as const;
}
interface UseProvidersSnapshotResult {
@@ -59,23 +27,17 @@ interface UseProvidersSnapshotOptions {
export function useProvidersSnapshot(
serverId: string | null,
cwd?: string | null,
options: UseProvidersSnapshotOptions = {},
): UseProvidersSnapshotResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const enabled = options.enabled ?? true;
const normalizedCwd = cwd?.trim() || undefined;
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
const supportsSnapshot = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
);
const queryKey = useMemo(
() => providersSnapshotQueryKey(serverId, normalizedCwdKey),
[normalizedCwdKey, serverId],
);
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
const snapshotQuery = useQuery({
queryKey,
@@ -85,7 +47,7 @@ export function useProvidersSnapshot(
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
return client.getProvidersSnapshot({});
},
});
@@ -94,9 +56,7 @@ export function useProvidersSnapshot(
if (!client) {
return;
}
await client.refreshProvidersSnapshot(
refreshProvidersSnapshotRequest(normalizedCwd, providers),
);
await client.refreshProvidersSnapshot(providers ? { providers } : {});
},
});
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
@@ -110,9 +70,6 @@ export function useProvidersSnapshot(
if (message.type !== "providers_snapshot_update") {
return;
}
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
return;
}
queryClient.setQueryData(queryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
@@ -127,17 +84,7 @@ export function useProvidersSnapshot(
});
}
});
}, [
client,
enabled,
isConnected,
normalizedCwd,
normalizedCwdKey,
queryClient,
queryKey,
serverId,
supportsSnapshot,
]);
}, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
const refresh = useCallback(
async (providers?: AgentProvider[]) => {
@@ -145,10 +92,10 @@ export function useProvidersSnapshot(
return;
}
await refreshSnapshot(providers);
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
const snapshot = await client.getProvidersSnapshot({});
queryClient.setQueryData(queryKey, snapshot);
},
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
[client, queryClient, queryKey, refreshSnapshot],
);
const refetchIfStale = useCallback(
@@ -184,16 +131,11 @@ export function useProvidersSnapshot(
};
}
export function prefetchProvidersSnapshot(
serverId: string,
client: DaemonClient,
cwd?: string | null,
): void {
const normalizedCwd = cwd?.trim() || undefined;
const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd);
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
const queryKey = providersSnapshotQueryKey(serverId);
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
queryFn: () => client.getProvidersSnapshot({}),
});
}

View File

@@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind =
| "voice-mute-toggle";
export type KeyboardActionId =
| "agent.interrupt"
| "agent.new"
| "workspace.tab.new"
| "workspace.tab.close.current"

View File

@@ -1,6 +1,7 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type KeyboardActionId =
| "agent.interrupt"
| "message-input.focus"
| "message-input.send"
| "message-input.dictation-toggle"
@@ -29,6 +30,7 @@ export type KeyboardActionId =
| "worktree.archive";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
| { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.send"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }

View File

@@ -280,11 +280,10 @@ describe("keyboard-shortcuts", () => {
payload: { kind: "voice-mute-toggle" },
},
{
name: "lets Escape continue to local handlers while routing dictation cancel",
name: "routes Escape to agent interrupt outside terminal focus",
event: { key: "Escape", code: "Escape" },
context: { focusScope: "message-input" },
action: "message-input.action",
payload: { kind: "dictation-cancel" },
action: "agent.interrupt",
preventDefault: false,
stopPropagation: false,
},
@@ -353,6 +352,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
},
{
name: "does not interrupt agent when terminal is focused",
event: { key: "Escape", code: "Escape" },
context: { focusScope: "terminal" },
},
{
name: "does not interrupt agent when command center is open",
event: { key: "Escape", code: "Escape" },
context: { commandCenterOpen: true },
},
{
name: "does not bind pane shortcuts on non-mac platforms",
event: { key: "\\", code: "Backslash", ctrlKey: true },

View File

@@ -869,17 +869,16 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
id: "message-input-dictation-cancel",
action: "message-input.action",
id: "agent-interrupt",
action: "agent.interrupt",
combo: "Escape",
when: { commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "dictation-cancel" },
preventDefault: false,
stopPropagation: false,
help: {
id: "dictation-cancel",
id: "agent-interrupt",
section: "agent-input",
label: "Cancel dictation",
label: "Interrupt agent",
keys: ["Esc"],
},
},

View File

@@ -43,6 +43,7 @@ const {
streamRenderCount,
latestStreamPermissionKeys,
latestStreamText,
runtimeIsConnected,
theme,
runtimeClient,
} = vi.hoisted(() => {
@@ -71,6 +72,7 @@ const {
streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null },
runtimeIsConnected: { current: false },
theme: {
colors: {
foreground: "#ffffff",
@@ -121,8 +123,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles)
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => false,
useHostRuntimeConnectionStatus: () => "offline",
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
useHostRuntimeLastError: () => null,
}));
@@ -240,6 +242,51 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
};
}
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());
@@ -367,6 +414,9 @@ describe("AgentPanel render isolation", () => {
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 () => {
@@ -463,4 +513,49 @@ describe("AgentPanel render isolation", () => {
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,
});
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);
});
});

View File

@@ -1,53 +1,54 @@
import type { DaemonClient } from "@server/client/daemon-client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { Composer } from "@/components/composer";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone";
import { getProviderIcon } from "@/components/provider-icons";
import type { ImageAttachment } from "@/components/message-input";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { isNative } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import {
useAgentScreenStateMachine,
type AgentScreenAgent,
type AgentScreenMissingState,
useAgentScreenStateMachine,
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import {
type HostRuntimeConnectionStatus,
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
useHostRuntimeIsConnected,
useHostRuntimeLastError,
useHosts,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { isNative } from "@/constants/platform";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) {
@@ -74,13 +75,63 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
return normalized;
}
function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
return !agent.archivedAt && Boolean(agent.projectPlacement);
}
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
function storeFetchedAgentDetail(input: {
serverId: string;
result: NonNullable<FetchAgentResult>;
}): Agent {
const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId);
const hydrated: Agent = {
...normalized,
projectPlacement: input.result.project,
};
const store = useSessionStore.getState();
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
store.setAgents(input.serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
} else {
store.setAgentDetails(input.serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
}
store.setPendingPermissions(input.serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
return hydrated;
}
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const descriptorState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
const session = state.sessions[context.serverId];
const agent =
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
@@ -262,17 +313,27 @@ function AgentPanelBody({
const { theme } = useUnistyles();
const { isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const setAgents = useSessionStore((state) => state.setAgents);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(state) => {
if (!agentId) {
return null;
}
const session = state.sessions[serverId];
return (
session?.agents?.get(agentId)?.projectPlacement ??
session?.agentDetails?.get(agentId)?.projectPlacement ??
null
);
},
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const agentState = useSessionStore(
useShallow((state) => {
const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
const session = state.sessions[serverId];
const agent = agentId
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
: null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
@@ -325,29 +386,7 @@ function AgentPanelBody({
return;
}
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: result.project,
};
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
storeFetchedAgentDetail({ serverId, result });
setLookupState({ tag: "idle" });
})
.catch((error) => {
@@ -361,17 +400,7 @@ function AgentPanelBody({
}
setLookupState({ tag: "error", message });
});
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
serverId,
setAgents,
setPendingPermissions,
]);
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
if (lookupState.tag === "not_found") {
return (
@@ -471,7 +500,10 @@ function ChatAgentContent({
const agentState = useSessionStore(
useShallow((state) => {
const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
const session = state.sessions[serverId];
const agent = agentId
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
: null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
@@ -486,8 +518,17 @@ function ChatAgentContent({
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(state) => {
if (!agentId) {
return null;
}
const session = state.sessions[serverId];
return (
session?.agents?.get(agentId)?.projectPlacement ??
session?.agentDetails?.get(agentId)?.projectPlacement ??
null
);
},
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
@@ -505,8 +546,6 @@ function ChatAgentContent({
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
const setAgents = useSessionStore((state) => state.setAgents);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({
serverId,
@@ -776,7 +815,9 @@ function ChatAgentContent({
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
const currentSession = useSessionStore.getState().sessions[serverId];
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
if (!currentAgent) {
const result = await client.fetchAgent(agentId);
if (attemptToken !== initAttemptTokenRef.current) {
@@ -789,29 +830,7 @@ function ChatAgentContent({
});
return;
}
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: result.project,
};
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
storeFetchedAgentDetail({ serverId, result });
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
@@ -838,8 +857,6 @@ function ChatAgentContent({
isConnected,
missingAgentState.kind,
serverId,
setAgents,
setPendingPermissions,
shouldUseOptimisticStream,
]);

View File

@@ -213,17 +213,21 @@ function makeDeps(
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id];
if (value instanceof Error) {
throw value;
}
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
const readLatency = (): number => {
const value = latencyByConnectionId[connection.id];
if (value instanceof Error) {
throw value;
}
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
return value;
};
readLatency();
const client = new FakeDaemonClient();
client.connectCalls = 1;
client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: value });
client.ping = async () => ({ rttMs: readLatency() });
createdClients.push(client);
return {
client: client as unknown as DaemonClient,
@@ -433,7 +437,95 @@ describe("HostRuntimeController", () => {
await probeCycle;
});
it("fails over when active connection becomes unavailable", async () => {
it("probes the active online connection through the existing client", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const probeAttempts: string[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should adopt probe clients");
},
connectToDaemon: async ({ host, connection }) => {
probeAttempts.push(connection.id);
const value = latencies[connection.id];
if (value instanceof Error) {
throw value;
}
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
return {
client: makeConnectedProbeClient(value) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
probeAttempts.length = 0;
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
activeClient.ping = async () => ({ rttMs: 9 });
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(probeAttempts).toEqual(["relay:relay.paseo.sh:443"]);
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "available",
latencyMs: 9,
});
});
it("rejects probes that resolve to a different server id", async () => {
const host = makeHost({
serverId: "srv_old",
connections: [
{
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
},
],
});
const mismatchedClient = makeConnectedProbeClient(8);
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should not create active client");
},
connectToDaemon: async () => ({
client: mismatchedClient as unknown as DaemonClient,
serverId: "srv_current",
hostname: "current host",
}),
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
expect(controller.getSnapshot().activeConnectionId).toBeNull();
expect(controller.getSnapshot().probeByConnectionId.get("direct:localhost:6767")).toEqual({
status: "unavailable",
latencyMs: null,
});
expect(mismatchedClient.closeCalls).toBe(1);
});
it("fails over when the active client ping fails", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
@@ -450,6 +542,9 @@ describe("HostRuntimeController", () => {
const initialClient = controller.getSnapshot().client;
expect(initialClient).toBeTruthy();
(initialClient as unknown as FakeDaemonClient).ping = async () => {
throw new Error("active ping failed");
};
latencies["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42;
clearProbeBackoff(controller);
@@ -838,7 +933,7 @@ describe("HostRuntimeController", () => {
expect(createdClients[0]?.closeCalls).toBe(1);
});
it("ignores stale probe results when overlapping probe cycles finish out of order", async () => {
it("coalesces overlapping probe cycles instead of invalidating the in-flight result", async () => {
const host = makeHost({
connections: [
{
@@ -849,7 +944,6 @@ describe("HostRuntimeController", () => {
],
});
const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0;
const controller = new HostRuntimeController({
@@ -865,9 +959,6 @@ describe("HostRuntimeController", () => {
if (probeCalls === 1) {
return { rttMs: await slowProbe.promise };
}
if (probeCalls === 2) {
return { rttMs: await fastProbe.promise };
}
throw new Error("unexpected probe call");
};
return {
@@ -883,23 +974,14 @@ describe("HostRuntimeController", () => {
const first = controller.runProbeCycleNow();
clearProbeBackoff(controller);
const second = controller.runProbeCycleNow();
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
expect(probeCalls).toBe(1);
slowProbe.resolve(900);
await first;
const probeAfterFirstSettles = controller
.getSnapshot()
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
await Promise.all([first, second]);
const probeAfterCycle = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
expect(probeAfterCycle).toEqual({
status: "available",
latencyMs: 12,
latencyMs: 900,
});
});
@@ -1112,7 +1194,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 },
@@ -1160,7 +1242,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 },
@@ -1169,7 +1251,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
it("fetches all pages during bootstrap within the active agent scope", async () => {
const host = makeHost({
serverId: "srv_paged",
connections: [
@@ -1234,13 +1316,13 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_paged" },
page: { limit: 200 },
});
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-page-2" },
});
@@ -1314,13 +1396,13 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toEqual([
{
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
},
{
filter: { includeArchived: true },
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
@@ -1331,7 +1413,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
it("replaces stale active session state when active bootstrap omits an agent", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
connections: [
@@ -1346,15 +1428,7 @@ describe("HostRuntimeStore", () => {
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:30:00.000Z",
archivedAt: "2026-03-30T15:31:00.000Z",
title: "Archived remotely",
}),
],
entries: [],
subscriptionId: "app:srv_archived_rehydrate",
}),
);
@@ -1397,17 +1471,16 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
let archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
while (!archivedAt && Date.now() < timeoutAt) {
while (
useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
}
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe(
false,
);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);

View File

@@ -4,13 +4,14 @@ import equal from "fast-deep-equal/es6";
import {
DaemonClient,
type ConnectionState,
type FetchAgentsEntry,
type FetchAgentsOptions,
} from "@server/client/daemon-client";
import {
connectionFromListen,
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasDirectEndpoint,
registryHasConnection,
type HostConnection,
type HostProfile,
} from "@/types/host-connection";
@@ -31,8 +32,8 @@ import {
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { isDev } from "@/constants/platform";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
@@ -488,6 +489,7 @@ export class HostRuntimeController {
private clientIdHash: string | null = null;
private switchRequestVersion = 0;
private probeRequestVersion = 0;
private probeCycleInFlight: Promise<void> | null = null;
constructor(input: {
host: HostProfile;
@@ -628,6 +630,20 @@ export class HostRuntimeController {
}
async runProbeCycleNow(): Promise<void> {
if (this.probeCycleInFlight) {
return this.probeCycleInFlight;
}
const cycle = this.runProbeCycle().finally(() => {
if (this.probeCycleInFlight === cycle) {
this.probeCycleInFlight = null;
}
});
this.probeCycleInFlight = cycle;
return cycle;
}
private async runProbeCycle(): Promise<void> {
const requestVersion = ++this.probeRequestVersion;
if (this.host.connections.length === 0) {
if (!this.isCurrentProbeRequest(requestVersion)) {
@@ -663,10 +679,15 @@ export class HostRuntimeController {
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
for (const connection of connectionsToProbe) {
this.connectionLastProbedAt.set(connection.id, performance.now());
probeByConnectionId.set(connection.id, {
status: "pending",
latencyMs: null,
});
const existingProbe = probeByConnectionId.get(connection.id);
const shouldPreserveActiveLatency =
isOnline && connection.id === activeConnectionId && existingProbe?.status === "available";
if (!shouldPreserveActiveLatency) {
probeByConnectionId.set(connection.id, {
status: "pending",
latencyMs: null,
});
}
}
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
@@ -800,22 +821,39 @@ export class HostRuntimeController {
for (const connection of connectionsToProbe) {
void (async () => {
let connectedClient: DaemonClient | null = null;
let handedOffClient = false;
let shouldCloseClient = false;
try {
const { client } = await this.deps.connectToDaemon({
host: this.host,
connection,
});
connectedClient = client;
const activeClient =
this.snapshot.connectionStatus === "online" &&
this.snapshot.activeConnectionId === connection.id
? this.snapshot.client
: null;
if (activeClient) {
connectedClient = activeClient;
} else {
const { client, serverId } = await this.deps.connectToDaemon({
host: this.host,
connection,
});
if (serverId !== this.host.serverId) {
await client.close().catch(() => undefined);
throw new Error(
`Connection resolved to ${serverId}, expected ${this.host.serverId}.`,
);
}
connectedClient = client;
shouldCloseClient = true;
}
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
}
const activated = await maybeActivateFirstAvailable(connection.id, client);
handedOffClient = activated;
const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await client.ping({ timeoutMs: 5000 });
const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
}
@@ -834,7 +872,7 @@ export class HostRuntimeController {
publishProbeState();
}
} finally {
if (connectedClient && !handedOffClient) {
if (connectedClient && shouldCloseClient) {
await connectedClient.close().catch(() => undefined);
}
settleProbe();
@@ -1099,7 +1137,6 @@ export class HostRuntimeController {
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
@@ -1229,33 +1266,22 @@ export class HostRuntimeStore {
private async bootstrapLocalhost(): Promise<void> {
try {
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY);
if (alreadyHandled) {
return;
}
if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
if (!connection || registryHasConnection(this.hosts, connection)) {
return;
}
try {
const { client, serverId, hostname } = await connectToDaemon(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: "directTcp",
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS },
);
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertDirectConnection({
await this.upsertHostConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
connection,
existingClient: client,
});
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
} catch {
// Best-effort bootstrap only
}
@@ -1759,7 +1785,7 @@ export class HostRuntimeStore {
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}): Promise<{
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
subscriptionId: string | null;
}> {
const controller = this.controllers.get(input.serverId);
@@ -1778,23 +1804,18 @@ export class HostRuntimeStore {
let cursor = input.page?.cursor ?? null;
let includeSubscribe = true;
let subscriptionId: string | null = null;
const allAgents = new Map<string, Agent>();
const allEntries: FetchAgentsEntry[] = [];
while (true) {
const payload = await client.fetchAgents({
filter: input.filter ?? { includeArchived: true },
scope: input.filter ? undefined : "active",
...(input.filter ? { filter: input.filter } : {}),
sort: DEFAULT_AGENT_DIRECTORY_SORT,
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
});
const pageAgents = applyFetchedAgentDirectory({
serverId: input.serverId,
entries: payload.entries,
}).agents;
for (const [agentId, agent] of pageAgents) {
allAgents.set(agentId, agent);
}
allEntries.push(...payload.entries);
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false;
@@ -1810,9 +1831,14 @@ export class HostRuntimeStore {
cursor = nextCursor;
}
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: allEntries,
});
controller.markAgentDirectorySyncReady();
return {
agents: allAgents,
agents,
subscriptionId,
};
} catch (error) {

View File

@@ -13,8 +13,10 @@ const {
theme,
mockClient,
mergeWorkspacesMock,
setAgentsMock,
navigateMock,
saveDraftInputMock,
clearDraftInputMock,
queueDraftSubmissionMock,
createdAgent,
createdWorkspace,
prItem,
@@ -113,8 +115,10 @@ const {
theme,
mockClient,
mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(),
saveDraftInputMock: vi.fn(),
clearDraftInputMock: vi.fn(),
queueDraftSubmissionMock: vi.fn(),
createdAgent,
createdWorkspace,
prItem,
@@ -159,6 +163,21 @@ vi.mock("lucide-react-native", () => {
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({
testID,
style,
...props
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
const flattenedStyle = Array.isArray(style)
? Object.assign({}, ...style.filter(Boolean))
: style;
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
},
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
@@ -168,7 +187,6 @@ vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
}));
@@ -195,6 +213,36 @@ 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() }),
}));
@@ -222,6 +270,10 @@ vi.mock("@/hooks/use-agent-input-draft", () => ({
},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
}));
vi.mock("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
@@ -444,6 +496,10 @@ beforeEach(() => {
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
saveDraftInputMock.mockClear();
clearDraftInputMock.mockClear();
queueDraftSubmissionMock.mockClear();
navigateMock.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
@@ -522,6 +578,14 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
}
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("searches only GitHub PRs for the picker", async () => {
renderScreen();
await flush();
@@ -552,6 +616,50 @@ describe("NewWorkspaceScreen picker payload", () => {
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(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();
@@ -747,10 +855,13 @@ describe("NewWorkspaceScreen picker payload", () => {
);
});
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
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 createAgent = createDeferredPromise<typeof createdAgent>();
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
const createWorktree = createDeferredPromise<{
workspace: typeof createdWorkspace;
error: null;
}>();
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
renderScreen();
await flush();
@@ -777,8 +888,9 @@ describe("NewWorkspaceScreen picker payload", () => {
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();
createAgent.reject(new Error("Create agent failed"));
createWorktree.reject(new Error("Create worktree failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
@@ -16,12 +17,13 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { toErrorMessage } from "@/utils/error-messages";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
@@ -105,9 +107,11 @@ export function NewWorkspaceScreen({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
@@ -131,8 +135,9 @@ export function NewWorkspaceScreen({
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
const chatDraft = useAgentInputDraft({
draftKey: `new-workspace:${serverId}:${sourceDirectory}`,
draftKey,
initialCwd: sourceDirectory,
composer: {
initialServerId: serverId || null,
@@ -319,10 +324,6 @@ export function NewWorkspaceScreen({
try {
setPendingAction("chat");
setErrorMessage(null);
const { images, attachments: reviewAttachments } =
splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Composer state is required");
}
@@ -330,15 +331,30 @@ export function NewWorkspaceScreen({
throw new Error("Select a model");
}
const initialPrompt = text.trim();
const encodedImages = await encodeImages(images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const draftId = generateDraftId();
const workspaceDirectory = workspace.workspaceDirectory;
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({
serverId,
agentId: draftId,
draftId,
}),
draft: {
text,
attachments,
cwd: workspaceDirectory,
},
});
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId: workspace.id,
draftId,
text,
attachments,
cwd: workspaceDirectory,
provider: composerState.selectedProvider,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -346,31 +362,24 @@ export function NewWorkspaceScreen({
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(initialPrompt ? { initialPrompt } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
});
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
allowEmptyText: true,
});
navigateToPreparedWorkspaceTab({
serverId,
workspaceId: workspace.id,
target: { kind: "agent", agentId: agent.id },
target: { kind: "draft", draftId },
navigationMethod: "replace",
});
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
setErrorMessage(message);
toast.error(message);
} finally {
setPendingAction(null);
}
},
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient],
[composerState, draftKey, ensureWorkspace, serverId, toast],
);
const workspaceTitle =
@@ -494,7 +503,10 @@ export function NewWorkspaceScreen({
}
onAddImages={handleAddImagesCallback}
/>
<View style={styles.optionsRow}>
<Animated.View
testID="new-workspace-ref-picker-row"
style={[styles.optionsRow, keyboardAnimatedStyle]}
>
<View>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
@@ -557,7 +569,7 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption}
/>
</View>
</View>
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</View>

View File

@@ -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", { 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");
});
});

View File

@@ -2,12 +2,13 @@ import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
@@ -21,10 +22,11 @@ export function SessionsScreen({ serverId }: { serverId: string }) {
}
function SessionsScreenContent({ serverId }: { serverId: string }) {
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
serverId,
includeArchived: true,
});
const { theme } = useUnistyles();
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
useAgentHistory({
serverId,
});
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -48,7 +50,11 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
{sortedAgents.length === 0 ? (
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
</View>
) : sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
@@ -65,6 +71,15 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={
hasMore ? (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</View>
) : null
}
showAttentionIndicator={false}
/>
)}
@@ -88,4 +103,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
footer: {
alignItems: "center",
paddingVertical: theme.spacing[4],
},
}));

View File

@@ -61,10 +61,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
buildHostOpenProjectRoute,
buildHostWorkspaceRoute,
buildSettingsHostRoute,
buildSettingsSectionRoute,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store";
// ---------------------------------------------------------------------------
// View model
@@ -720,6 +722,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) {
router.back();
return;
@@ -729,7 +740,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return;
}
router.replace("/");
}, [anyOnlineServerId, router]);
}, [anyOnlineServerId, isCompactLayout, router]);
const detailHeader = ((): {
title: string;
@@ -887,6 +898,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<View style={desktopStyles.contentPane}>
<ScreenHeader
borderless={!detailHeader}
windowControlsPaddingRole="detailHeader"
left={
detailHeader ? (
<>

View File

@@ -252,7 +252,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.container}>
<TitlebarDragRegion />
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up</Text>
<Text style={styles.subtitle}>Connecting</Text>
</View>
);
}

View File

@@ -88,6 +88,25 @@ describe("workspace agent visibility", () => {
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
});
it("treats lazy historical details as known without making them active", () => {
const workspaceDirectory = "/repo/worktree";
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
const historicalDetail = makeAgent({
id: "historical-agent",
cwd: workspaceDirectory,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const result = deriveWorkspaceAgentVisibility({
sessionAgents: new Map([[active.id, active]]),
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
workspaceDirectory,
});
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"]));
});
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
const knownAgentIds = new Set(["archived-agent"]);
const activeAgentIds = new Set<string>();

View File

@@ -12,11 +12,12 @@ export interface WorkspaceAgentVisibility {
export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined;
agentDetails?: Map<string, Agent> | undefined;
workspaceDirectory: string | null | undefined;
}): WorkspaceAgentVisibility {
const { sessionAgents, workspaceDirectory } = input;
const { sessionAgents, agentDetails, workspaceDirectory } = input;
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
if (!sessionAgents || !normalizedWorkspaceDirectory) {
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
return {
activeAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
@@ -25,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const activeAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
for (const agent of sessionAgents?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
continue;
}
@@ -34,6 +35,12 @@ export function deriveWorkspaceAgentVisibility(input: {
activeAgentIds.add(agent.id);
}
}
for (const agent of agentDetails?.values() ?? []) {
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
continue;
}
knownAgentIds.add(agent.id);
}
return { activeAgentIds, knownAgentIds };
}

View File

@@ -219,7 +219,10 @@ function TabChip({
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityRole="button"
accessibilityLabel={tooltipLabel}
accessibilityState={{ selected: isActive }}
aria-selected={isActive}
>
{isActive && (
<View

View File

@@ -0,0 +1,270 @@
/**
* @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,
latestComposerText,
latestComposerDisabled,
latestStreamText,
onCreatedMock,
} = vi.hoisted(() => ({
createAgentMock: 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,
},
}));
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,
}),
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("@/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();
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("");
});
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,6 +14,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
@@ -80,6 +81,26 @@ export function WorkspaceDraftAgentTab({
if (!composerState) {
throw new Error("Workspace draft composer state is required");
}
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const setDraftAttachments = draftInput.setAttachments;
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
});
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
(state) => state.consumePending,
);
const autoSubmitConfig = pendingAutoSubmit
? {
provider: pendingAutoSubmit.provider,
modeId: pendingAutoSubmit.modeId ?? null,
model: pendingAutoSubmit.model ?? null,
thinkingOptionId: pendingAutoSubmit.thinkingOptionId ?? null,
featureValues: pendingAutoSubmit.featureValues,
}
: null;
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const {
formErrorMessage,
@@ -90,20 +111,21 @@ export function WorkspaceDraftAgentTab({
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) => {
if (!text.trim()) {
if (!allowsEmptyAutoSubmit && !text.trim()) {
return "Initial prompt is required";
}
if (composerState.providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!composerState.selectedProvider) {
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
return "Select a model";
}
if (composerState.isModelLoading) {
return "Model defaults are still loading";
}
if (!composerState.effectiveModelId) {
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
return "No model is available for the selected provider";
}
if (!workspaceDirectory) {
@@ -124,13 +146,15 @@ export function WorkspaceDraftAgentTab({
buildDraftAgent: (attempt) => {
invariant(workspaceDirectory, "Workspace directory is required");
const now = attempt.timestamp;
const model = composerState.effectiveModelId || null;
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
const thinkingOptionId =
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
autoSubmitConfig?.modeId ??
(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: null;
const provider = composerState.selectedProvider;
: null);
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
@@ -164,19 +188,23 @@ export function WorkspaceDraftAgentTab({
throw new Error("Host is not connected");
}
const provider = composerState.selectedProvider;
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config = buildWorkspaceDraftAgentConfig({
provider,
cwd: workspaceDirectory,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
...(autoSubmitConfig?.modeId
? { modeId: autoSubmitConfig.modeId }
: composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ??
(composerState.effectiveThinkingOptionId || undefined),
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
@@ -195,10 +223,55 @@ export function WorkspaceDraftAgentTab({
};
},
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
onCreated(result);
},
});
const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit &&
draftInput.isHydrated &&
workspaceDirectory &&
client &&
!isSubmitting &&
!composerState.isModelLoading,
);
const autoSubmitKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isReadyForPendingAutoSubmit) {
return;
}
const submitKey = `${serverId}:${workspaceId}:${draftId}`;
if (autoSubmitKeyRef.current === submitKey) {
return;
}
const submission = consumePendingAutoSubmit({ serverId, workspaceId, draftId });
if (!submission) {
return;
}
autoSubmitKeyRef.current = submitKey;
setDraftText("");
setDraftAttachments([]);
void handleCreateFromInput({
text: submission.text,
attachments: submission.attachments,
cwd: submission.cwd,
}).catch(() => {
setDraftText(submission.text);
setDraftAttachments(submission.attachments);
autoSubmitKeyRef.current = null;
});
}, [
consumePendingAutoSubmit,
draftId,
handleCreateFromInput,
isReadyForPendingAutoSubmit,
serverId,
setDraftAttachments,
setDraftText,
workspaceId,
]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { shouldSeedEmptyWorkspaceDraft } from "./workspace-empty-draft-seed";
const readyEmptyWorkspace = {
isRouteFocused: true,
hasPersistenceKey: true,
hasWorkspaceDirectory: true,
hasHydratedWorkspaceLayoutStore: true,
hasHydratedAgents: true,
hasLoadedTerminals: true,
activeAgentCount: 0,
terminalCount: 0,
tabCount: 0,
};
describe("shouldSeedEmptyWorkspaceDraft", () => {
it("waits for refresh-time hydration before seeding a draft", () => {
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasHydratedWorkspaceLayoutStore: false,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasHydratedAgents: false,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasLoadedTerminals: false,
}),
).toBe(false);
});
it("does not seed when existing workspace content is known", () => {
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
activeAgentCount: 1,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
terminalCount: 1,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
tabCount: 1,
}),
).toBe(false);
});
it("seeds once an empty focused workspace is fully known", () => {
expect(shouldSeedEmptyWorkspaceDraft(readyEmptyWorkspace)).toBe(true);
});
});

View File

@@ -0,0 +1,24 @@
export function shouldSeedEmptyWorkspaceDraft(input: {
isRouteFocused: boolean;
hasPersistenceKey: boolean;
hasWorkspaceDirectory: boolean;
hasHydratedWorkspaceLayoutStore: boolean;
hasHydratedAgents: boolean;
hasLoadedTerminals: boolean;
activeAgentCount: number;
terminalCount: number;
tabCount: number;
}): boolean {
if (
!input.isRouteFocused ||
!input.hasPersistenceKey ||
!input.hasWorkspaceDirectory ||
!input.hasHydratedWorkspaceLayoutStore ||
!input.hasHydratedAgents ||
!input.hasLoadedTerminals
) {
return false;
}
return input.activeAgentCount === 0 && input.terminalCount === 0 && input.tabCount === 0;
}

View File

@@ -1,5 +1,41 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export type WorkspaceHeaderCheckoutState =
| { kind: "pending" }
| { kind: "error" }
| { kind: "ready"; checkout: { isGit: boolean; currentBranch: string | null } };
type WorkspaceHeaderRenderState =
| { kind: "skeleton" }
| {
kind: "ready";
title: string;
subtitle: string;
shouldShowSubtitle: boolean;
isGitCheckout: boolean;
currentBranchName: string | null;
};
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function areHeaderLabelsEquivalent(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
if (!normalizedA || !normalizedB) {
return false;
}
return normalizedA === normalizedB;
}
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
title: string;
subtitle: string;
@@ -10,6 +46,35 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
};
}
export function resolveWorkspaceHeaderRenderState(input: {
workspace: WorkspaceDescriptor | null;
checkoutState: WorkspaceHeaderCheckoutState;
}): WorkspaceHeaderRenderState {
if (!input.workspace) {
return { kind: "skeleton" };
}
if (input.checkoutState.kind === "pending" && input.workspace.projectKind === "git") {
return { kind: "skeleton" };
}
const header = resolveWorkspaceHeader({ workspace: input.workspace });
const checkout = input.checkoutState.kind === "ready" ? input.checkoutState.checkout : null;
const currentBranchName =
checkout?.isGit && checkout.currentBranch !== "HEAD"
? trimNonEmpty(checkout.currentBranch)
: null;
return {
kind: "ready",
title: header.title,
subtitle: header.subtitle,
shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle),
isGitCheckout: checkout?.isGit ?? false,
currentBranchName,
};
}
export function shouldRenderMissingWorkspaceDescriptor(input: {
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;

View File

@@ -55,6 +55,7 @@ import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -66,7 +67,7 @@ import {
} from "@/utils/workspace-tab-identity";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import {
@@ -96,8 +97,9 @@ import {
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
resolveWorkspaceHeader,
resolveWorkspaceHeaderRenderState,
shouldRenderMissingWorkspaceDescriptor,
type WorkspaceHeaderCheckoutState,
} from "@/screens/workspace/workspace-header-source";
import {
deriveWorkspaceAgentVisibility,
@@ -110,6 +112,7 @@ import {
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
@@ -153,18 +156,6 @@ function decodeSegment(value: string): string {
}
}
function areHeaderLabelsEquivalent(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
if (!normalizedA || !normalizedB) {
return false;
}
return normalizedA === normalizedB;
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
@@ -658,9 +649,8 @@ function WorkspaceScreenContent({
workspaceDescriptor && !workspaceExecutionAuthority,
);
// Warm the server-side provider snapshot for this workspace cwd so the model
// picker is ready when opened. Consumers share the same query cache key.
useProvidersSnapshot(normalizedServerId, workspaceDirectory, {
// Warm the global provider snapshot so the model picker is ready when opened.
useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused,
});
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
@@ -675,6 +665,7 @@ function WorkspaceScreenContent({
(state) =>
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
workspaceDirectory,
}),
workspaceAgentVisibilityEqual,
@@ -697,6 +688,55 @@ function WorkspaceScreenContent({
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = terminalsQuery.data?.terminals ?? [];
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
useEffect(() => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || terminalsQuery.dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
});
}, [liveTerminalIds, terminalsQuery.dataUpdatedAt]);
const knownTerminalIds = useMemo(() => {
const terminalIds = new Set(liveTerminalIds);
for (const terminalId of pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}, [liveTerminalIds, pendingScriptTerminalIds]);
const scriptTerminalIds = useMemo(() => {
const terminalIds = new Set(pendingScriptTerminalIds.keys());
for (const script of workspaceDescriptor?.scripts ?? []) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
@@ -830,21 +870,36 @@ function WorkspaceScreenContent({
pendingTerminalCreateInput,
toast,
]);
const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
workspaceHeaderTitle,
workspaceHeaderSubtitle,
);
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
let workspaceHeaderCheckoutState: WorkspaceHeaderCheckoutState;
if (isCheckoutStatusLoading) {
workspaceHeaderCheckoutState = { kind: "pending" };
} else if (checkoutQuery.isError || !checkoutQuery.data) {
workspaceHeaderCheckoutState = { kind: "error" };
} else {
workspaceHeaderCheckoutState = {
kind: "ready",
checkout: {
isGit: checkoutQuery.data.isGit,
currentBranch: checkoutQuery.data.currentBranch,
},
};
}
const workspaceHeaderRenderState = resolveWorkspaceHeaderRenderState({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
});
const isWorkspaceHeaderLoading = workspaceHeaderRenderState.kind === "skeleton";
const workspaceHeaderTitle =
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.title : "";
const workspaceHeaderSubtitle =
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.subtitle : "";
const shouldShowWorkspaceHeaderSubtitle =
workspaceHeaderRenderState.kind === "ready" && workspaceHeaderRenderState.shouldShowSubtitle;
const isGitCheckout =
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.isGitCheckout : false;
const currentBranchName =
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
? trimNonEmpty(checkoutQuery.data.currentBranch)
workspaceHeaderRenderState.kind === "ready"
? workspaceHeaderRenderState.currentBranchName
: null;
const isExplorerOpen = usePanelStore((state) =>
@@ -924,9 +979,12 @@ function WorkspaceScreenContent({
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
);
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
@@ -948,6 +1006,38 @@ function WorkspaceScreenContent({
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -1051,6 +1141,9 @@ function WorkspaceScreenContent({
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") {
@@ -1065,16 +1158,19 @@ function WorkspaceScreenContent({
terminalsHydrated: terminalsQuery.isSuccess,
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
knownTerminalIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
});
}, [
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,
isRouteFocused,
pendingByDraftId,
persistenceKey,
reconcileWorkspaceTabs,
terminals,
knownTerminalIds,
standaloneTerminalIds,
terminalsQuery.isSuccess,
uiTabs,
workspaceAgentVisibility,
@@ -1107,18 +1203,69 @@ function WorkspaceScreenContent({
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
const requestedWorkspaceSetupStatusKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused) {
return;
}
if (!persistenceKey) {
if (!client || !normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
return;
}
if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) {
emptyWorkspaceSeedRef.current = null;
if (workspaceSetupSnapshot) {
return;
}
if (tabs.length > 0) {
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
return;
}
requestedWorkspaceSetupStatusKeyRef.current = persistenceKey;
let isCancelled = false;
client
.fetchWorkspaceSetupStatus(normalizedWorkspaceId)
.then((response) => {
if (isCancelled || response.workspaceId !== normalizedWorkspaceId || !response.snapshot) {
return;
}
upsertWorkspaceSetupProgress({
serverId: normalizedServerId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
})
.catch(() => {
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
requestedWorkspaceSetupStatusKeyRef.current = null;
}
});
return () => {
isCancelled = true;
};
}, [
client,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
upsertWorkspaceSetupProgress,
workspaceSetupSnapshot,
]);
useEffect(() => {
if (
!shouldSeedEmptyWorkspaceDraft({
isRouteFocused,
hasPersistenceKey: Boolean(persistenceKey),
hasWorkspaceDirectory: Boolean(workspaceDirectory),
hasHydratedWorkspaceLayoutStore,
hasHydratedAgents,
hasLoadedTerminals: terminalsQuery.isSuccess,
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
terminalCount: terminals.length,
tabCount: tabs.length,
})
) {
emptyWorkspaceSeedRef.current = null;
return;
}
@@ -1133,9 +1280,13 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
openWorkspaceDraftTab,
persistenceKey,
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,
isRouteFocused,
terminals.length,
terminalsQuery.isSuccess,
tabs.length,
workspaceDirectory,
workspaceAgentVisibility.activeAgentIds.size,
]);
@@ -1146,7 +1297,7 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
if (!workspaceSetupSnapshot) {
if (!workspaceSetupSnapshot || !showWorkspaceSetup) {
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
autoOpenedSetupTabWorkspaceRef.current = null;
}
@@ -1188,6 +1339,7 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
openWorkspaceTabInBackground,
persistenceKey,
showWorkspaceSetup,
workspaceSetupSnapshot,
]);
@@ -2175,14 +2327,18 @@ function WorkspaceScreenContent({
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={<Settings size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleOpenSetupTab}
>
Show setup
</DropdownMenuItem>
{showWorkspaceSetup ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={<Settings size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleOpenSetupTab}
>
Show setup
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</View>
@@ -2195,6 +2351,9 @@ function WorkspaceScreenContent({
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
hideLabels={showCompactButtonLabels}
/>
) : null}

View File

@@ -0,0 +1,320 @@
/**
* @vitest-environment jsdom
*/
import React, { type ReactElement } from "react";
import { act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
const { theme, startWorkspaceScriptMock } = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { normal: "400", medium: "500" },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface2: "#222",
borderAccent: "#444",
palette: {
blue: { 500: "#0a84ff" },
green: { 500: "#30d158" },
red: { 300: "#ff9f99", 500: "#ff453a" },
},
},
};
return {
theme,
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })),
};
});
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
isWeb: true,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeSnapshot: () => ({ activeConnection: null }),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
sessions: {
"test-server": {
client: {
startWorkspaceScript: startWorkspaceScriptMock,
},
},
},
}),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ show: vi.fn(), error: vi.fn() }),
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuTrigger: ({
children,
testID,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
}) => (
<button type="button" data-testid={testID}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: true })
: children}
</button>
),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", {
"data-icon": name,
"data-color": props.color,
"data-size": props.size,
"data-testid": props.testID,
});
return {
ChevronDown: createIcon("ChevronDown"),
ExternalLink: createIcon("ExternalLink"),
Globe: createIcon("Globe"),
Play: createIcon("Play"),
SquareTerminal: createIcon("SquareTerminal"),
};
});
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
function script(
input: Partial<WorkspaceScriptPayload> & Pick<WorkspaceScriptPayload, "scriptName">,
): WorkspaceScriptPayload {
return {
scriptName: input.scriptName,
type: input.type ?? "script",
hostname: input.hostname ?? input.scriptName,
port: input.port ?? null,
proxyUrl: input.proxyUrl ?? null,
lifecycle: input.lifecycle ?? "stopped",
health: input.health ?? null,
exitCode: input.exitCode ?? null,
terminalId: input.terminalId ?? null,
};
}
function renderScripts(scripts: WorkspaceScriptPayload[]): {
rerender: (nextScripts: WorkspaceScriptPayload[]) => Promise<void>;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceScriptsButton
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
liveTerminalIds={["terminal-script-1"]}
/>
</QueryClientProvider>
);
}
act(() => {
root.render(element(scripts));
});
return {
rerender: async (nextScripts) => {
await act(async () => {
root.render(element(nextScripts));
});
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
function requireRow(scriptName: string): HTMLElement {
const row = document.querySelector(`[data-testid="workspace-scripts-item-${scriptName}"]`);
if (!(row instanceof HTMLElement)) {
throw new Error(`Missing script row for ${scriptName}`);
}
return row;
}
function requirePrimaryIcon(row: HTMLElement): HTMLElement {
const icon = row.querySelector("[data-icon]");
if (!(icon instanceof HTMLElement)) {
throw new Error("Missing row icon");
}
return icon;
}
describe("WorkspaceScriptsButton", () => {
let current: ReturnType<typeof renderScripts> | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
document.body.innerHTML = "";
startWorkspaceScriptMock.mockClear();
});
afterEach(() => {
current?.unmount();
current = null;
vi.unstubAllGlobals();
});
it("keeps completed script row icons visible and muted while the menu content stays mounted", async () => {
current = renderScripts([
script({
scriptName: "typecheck",
lifecycle: "running",
terminalId: "terminal-script-1",
}),
]);
let row = requireRow("typecheck");
let icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.palette.blue[500]);
await current.rerender([
script({
scriptName: "typecheck",
lifecycle: "stopped",
exitCode: 0,
terminalId: "terminal-script-1",
}),
]);
row = requireRow("typecheck");
icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("typecheck");
expect(row.textContent).toContain("exit 0");
expect(row.textContent).toContain("Run");
await current.rerender([
script({
scriptName: "typecheck",
lifecycle: "stopped",
exitCode: 7,
terminalId: "terminal-script-1",
}),
]);
row = requireRow("typecheck");
icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("exit 7");
expect(row.textContent).toContain("Run");
});
it("uses service icon color for service health and running unknown status only", () => {
current = renderScripts([
script({
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
lifecycle: "running",
health: "healthy",
port: 3000,
}),
script({
scriptName: "api",
type: "service",
hostname: "api.paseo.localhost",
lifecycle: "running",
health: "unhealthy",
port: 4000,
}),
script({
scriptName: "worker",
type: "service",
hostname: "worker.paseo.localhost",
lifecycle: "running",
health: null,
port: 5000,
}),
script({
scriptName: "old-service",
type: "service",
hostname: "old-service.paseo.localhost",
lifecycle: "stopped",
exitCode: 1,
}),
]);
expect(requirePrimaryIcon(requireRow("web")).dataset.color).toBe(
theme.colors.palette.green[500],
);
expect(requirePrimaryIcon(requireRow("api")).dataset.color).toBe(theme.colors.palette.red[500]);
expect(requirePrimaryIcon(requireRow("worker")).dataset.color).toBe(
theme.colors.palette.blue[500],
);
expect(requirePrimaryIcon(requireRow("old-service")).dataset.color).toBe(
theme.colors.foregroundMuted,
);
});
});

View File

@@ -1,14 +1,7 @@
import { type ReactElement } from "react";
import { Fragment, useMemo, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import {
ChevronDown,
ExternalLink,
Globe,
LoaderCircle,
Play,
SquareTerminal,
} from "lucide-react-native";
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
@@ -16,44 +9,153 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
type Script = WorkspaceDescriptor["scripts"][number];
function getScriptHealthColor(
health: Script["health"],
theme: ReturnType<typeof useUnistyles>["theme"],
): string {
if (health === "healthy") {
return theme.colors.palette.blue[500];
}
if (health === "unhealthy") {
return theme.colors.palette.red[500];
}
return theme.colors.foregroundMuted;
}
type ScriptActionIcon = "start" | "view";
interface WorkspaceScriptsButtonProps {
serverId: string;
workspaceId: string;
scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void;
hideLabels?: boolean;
}
interface ScriptActionButtonProps {
accessibilityLabel: string;
disabled?: boolean;
icon: ScriptActionIcon;
label: string;
onPress: () => void;
testID: string;
}
function ScriptActionButton({
accessibilityLabel,
disabled,
icon,
label,
onPress,
testID,
}: ScriptActionButtonProps): ReactElement {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
hitSlop={4}
disabled={disabled}
onPress={(event) => {
event.stopPropagation();
onPress();
}}
style={styles.actionButton}
>
{({ hovered }) => {
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconProps = { size: 10, color };
let iconElement: ReactElement;
if (icon === "view") {
iconElement = <SquareTerminal {...iconProps} />;
} else {
iconElement = <Play {...iconProps} fill="transparent" />;
}
return (
<>
{iconElement}
<Text style={[styles.actionButtonLabel, { color }]}>{label}</Text>
</>
);
}}
</Pressable>
);
}
function stripUrlProtocol(url: string): string {
return url.replace(/^https?:\/\//, "");
}
interface HostLinkProps {
label: string;
url: string | null;
scriptName: string;
}
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const { theme } = useUnistyles();
const disabled = !url;
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${scriptName} at ${label}`}
disabled={disabled}
hitSlop={2}
onPress={(event) => {
event.stopPropagation();
if (url) void openExternalUrl(url);
}}
style={styles.hostRow}
>
{({ hovered }) => {
const showIcon = !disabled && (hovered || isNative);
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Text style={[styles.hostLabel, { color }]} numberOfLines={1}>
{label}
</Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ExternalLink size={10} color={color} /> : null}
</View>
</>
);
}}
</Pressable>
);
}
function ExitCodeBadge({ code }: { code: number }): ReactElement {
const { theme } = useUnistyles();
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
return (
<View style={styles.exitBadge}>
<Text style={[styles.exitBadgeText, { color }]}>exit {code}</Text>
</View>
);
}
interface HostLink {
key: string;
label: string;
url: string | null;
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
scripts,
liveTerminalIds = [],
onScriptTerminalStarted,
onViewTerminal,
hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => {
@@ -71,6 +173,11 @@ export function WorkspaceScriptsButton({
variant: "error",
});
},
onSuccess: (result) => {
if (result.terminalId) {
onScriptTerminalStarted?.(result.terminalId);
}
},
});
if (scripts.length === 0) {
@@ -111,50 +218,90 @@ export function WorkspaceScriptsButton({
testID="workspace-scripts-menu"
>
<View style={styles.scriptList}>
{scripts.map((script) => {
{scripts.map((script, index) => {
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const isLinkable = isService && isRunning && !!serviceLink.openUrl;
const exitCode = script.exitCode ?? null;
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null;
const liveTerminalId =
script.terminalId && liveTerminalIdSet.has(script.terminalId)
? script.terminalId
: null;
let dotColor: string;
const hostLinks: HostLink[] = [];
if (isService && isRunning) {
const routedUrl = script.proxyUrl ?? serviceLink.labelUrl;
if (routedUrl) {
hostLinks.push({
key: "proxy",
label: stripUrlProtocol(routedUrl),
url: serviceOpenUrl,
});
}
if (script.port !== null) {
const localhostLabel = `localhost:${script.port}`;
const alreadyShown = hostLinks.some((l) => l.label === localhostLabel);
if (!alreadyShown) {
hostLinks.push({
key: "localhost",
label: localhostLabel,
url: `http://localhost:${script.port}`,
});
}
}
}
let iconColor = theme.colors.foregroundMuted;
if (isService) {
dotColor = isRunning
? getScriptHealthColor(script.health, theme)
: theme.colors.foregroundMuted;
if (isRunning && script.health === "healthy") {
iconColor = theme.colors.palette.green[500];
} else if (isRunning && script.health === "unhealthy") {
iconColor = theme.colors.palette.red[500];
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
} else if (isRunning) {
dotColor = theme.colors.palette.blue[500];
} else if (exitCode === 0) {
dotColor = theme.colors.palette.green[500];
} else if (exitCode !== null) {
dotColor = theme.colors.palette.red[500];
} else {
dotColor = theme.colors.foregroundMuted;
iconColor = theme.colors.palette.blue[500];
}
const ScriptIcon = isService ? Globe : SquareTerminal;
const showExitBadge = !isRunning && exitCode !== null;
let primaryAction: ReactElement | null = null;
if (isRunning && liveTerminalId) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`View ${script.scriptName} terminal`}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label="View"
onPress={() => onViewTerminal?.(liveTerminalId)}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={startScriptMutation.isPending}
icon="start"
label="Run"
onPress={() => startScriptMutation.mutate(script.scriptName)}
/>
);
}
return (
<Pressable
key={script.scriptName}
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityRole={isLinkable ? "link" : undefined}
accessibilityLabel={`${script.scriptName} script`}
style={({ hovered }) => [
styles.scriptRow,
hovered && isLinkable && styles.scriptRowHovered,
]}
onPress={
isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined
}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
{isService ? (
<Globe size={14} color={dotColor} style={styles.scriptIcon} />
) : (
<SquareTerminal size={14} color={dotColor} style={styles.scriptIcon} />
)}
<Fragment key={script.scriptName}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<View
testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityLabel={`${script.scriptName} script`}
style={styles.scriptItem}
>
<View style={styles.scriptHeader}>
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
<Text
style={[
styles.scriptName,
@@ -168,78 +315,24 @@ export function WorkspaceScriptsButton({
>
{script.scriptName}
</Text>
{isService && isRunning && serviceLink.labelUrl ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
{serviceLink.labelUrl.replace(/^https?:\/\//, "")}
</Text>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
<Text style={styles.scriptUrl} numberOfLines={1}>
exit {exitCode}
</Text>
) : (
<View style={styles.spacer} />
)}
{isRunning ? (
isLinkable && hovered ? (
<View
style={[
styles.externalLinkOverlay,
{
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
} as any,
]}
>
<ExternalLink size={12} color={theme.colors.foreground} />
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
hitSlop={4}
disabled={startScriptMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startScriptMutation.mutate(script.scriptName);
}}
style={styles.startButton}
>
{({ hovered: actionHovered }) =>
startScriptMutation.isPending &&
startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={10}
color={
actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
)}
</>
)}
</Pressable>
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<View style={styles.spacer} />
{primaryAction}
</View>
{hostLinks.length > 0 ? (
<View style={styles.hostList}>
{hostLinks.map((link) => (
<HostLinkRow
key={link.key}
label={link.label}
url={link.url}
scriptName={script.scriptName}
/>
))}
</View>
) : null}
</View>
</Fragment>
);
})}
</View>
@@ -270,11 +363,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
justifyContent: "center",
},
splitButtonPrimaryIconOnly: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
justifyContent: "center",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -293,16 +381,15 @@ const styles = StyleSheet.create((theme) => ({
scriptList: {
paddingVertical: theme.spacing[1],
},
scriptRow: {
scriptItem: {
paddingVertical: 6,
},
scriptHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: 6,
minHeight: 28,
},
scriptRowHovered: {
backgroundColor: theme.colors.surface2,
minHeight: 24,
},
scriptIcon: {
flexShrink: 0,
@@ -311,36 +398,53 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
lineHeight: 18,
flexShrink: 0,
},
scriptUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1,
flexShrink: 1,
minWidth: 0,
textAlign: "right",
},
spacer: {
flex: 1,
minWidth: 0,
},
externalLinkOverlay: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
hostList: {
marginTop: 2,
paddingHorizontal: theme.spacing[3],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
paddingVertical: 2,
minHeight: 18,
},
hostLabel: {
flexShrink: 1,
fontSize: theme.fontSize.xs,
lineHeight: 14,
},
hostIconSlot: {
width: 10,
height: 10,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
startButton: {
exitBadge: {
paddingHorizontal: theme.spacing[1.5],
paddingVertical: 1,
borderRadius: 2,
backgroundColor: theme.colors.surface2,
},
exitBadgeText: {
fontSize: 10,
lineHeight: 12,
fontWeight: theme.fontWeight.medium,
},
actionButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
startButtonLabel: {
actionButtonLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},

View File

@@ -6,26 +6,32 @@ vi.hoisted(() => {
import {
resolveWorkspaceHeader,
resolveWorkspaceHeaderRenderState,
shouldRenderMissingWorkspaceDescriptor,
} from "./workspace-header-source";
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
return {
id: "/repo/main",
projectId: "remote:github.com/getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
projectRootPath: "/repo/main",
workspaceDirectory: "/repo/main",
projectKind: "git",
workspaceKind: "local_checkout",
name: "feat/workspace-sot",
status: "running",
diffStat: null,
scripts: [],
...input,
};
}
describe("workspace source of truth consumption", () => {
it("uses the same descriptor name in header and sidebar row", () => {
const workspace: WorkspaceDescriptor = {
id: "/repo/main",
projectId: "remote:github.com/getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
projectRootPath: "/repo/main",
workspaceDirectory: "/repo/main",
projectKind: "git",
workspaceKind: "checkout",
name: "feat/workspace-sot",
status: "running",
diffStat: null,
scripts: [],
};
const workspace = createWorkspaceDescriptor();
const header = resolveWorkspaceHeader({ workspace });
const sidebarWorkspace = createSidebarWorkspaceEntry({
@@ -39,6 +45,104 @@ describe("workspace source of truth consumption", () => {
expect(sidebarWorkspace.statusBucket).toBe("running");
});
it("keeps the header skeleton while the workspace descriptor is missing", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: null,
checkoutState: { kind: "pending" },
}),
).toEqual({ kind: "skeleton" });
});
it("keeps git workspace headers skeletoned until checkout status resolves", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: createWorkspaceDescriptor({ projectKind: "git" }),
checkoutState: { kind: "pending" },
}),
).toEqual({ kind: "skeleton" });
});
it("renders known non-git workspace identity while checkout status is pending", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: createWorkspaceDescriptor({
projectKind: "non_git",
workspaceKind: "directory",
name: "notes",
projectDisplayName: "Local folders",
}),
checkoutState: { kind: "pending" },
}),
).toEqual({
kind: "ready",
title: "notes",
subtitle: "Local folders",
shouldShowSubtitle: true,
isGitCheckout: false,
currentBranchName: null,
});
});
it("renders git checkout headers with branch affordance after checkout status resolves", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: createWorkspaceDescriptor(),
checkoutState: {
kind: "ready",
checkout: { isGit: true, currentBranch: "feat/workspace-sot" },
},
}),
).toEqual({
kind: "ready",
title: "feat/workspace-sot",
subtitle: "getpaseo/paseo",
shouldShowSubtitle: true,
isGitCheckout: true,
currentBranchName: "feat/workspace-sot",
});
});
it("renders non-git checkout headers without branch affordance after checkout status resolves", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: createWorkspaceDescriptor({
projectKind: "non_git",
workspaceKind: "directory",
name: "notes",
projectDisplayName: "notes",
}),
checkoutState: {
kind: "ready",
checkout: { isGit: false, currentBranch: null },
},
}),
).toEqual({
kind: "ready",
title: "notes",
subtitle: "notes",
shouldShowSubtitle: false,
isGitCheckout: false,
currentBranchName: null,
});
});
it("renders descriptor identity after checkout status errors", () => {
expect(
resolveWorkspaceHeaderRenderState({
workspace: createWorkspaceDescriptor(),
checkoutState: { kind: "error" },
}),
).toEqual({
kind: "ready",
title: "feat/workspace-sot",
subtitle: "getpaseo/paseo",
shouldShowSubtitle: true,
isGitCheckout: false,
currentBranchName: null,
});
});
it("renders explicit missing state only after workspace hydration", () => {
expect(
shouldRenderMissingWorkspaceDescriptor({

View File

@@ -112,10 +112,18 @@ describe("navigation active workspace store", () => {
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
@@ -134,4 +142,35 @@ describe("navigation active workspace store", () => {
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
});
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
store.overrideNextNavigationWorkspaceRouteSelection({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
});

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