Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e1c786e70b feat(app): store assistant image previews as assets 2026-04-22 22:23:10 +07:00
github-actions[bot] de57f301f9 fix: update lockfile signatures and Nix hash 2026-04-22 13:40:36 +00:00
Mohamed Boudra 95c773ce6d chore(release): cut 0.1.60 2026-04-22 20:38:54 +07:00
Mohamed Boudra c6680b3001 fix(release): preserve 0.1.60 schema compatibility 2026-04-22 20:37:19 +07:00
Mohamed Boudra 484e453401 Fix timeline catch-up for projected overlap 2026-04-22 20:21:26 +07:00
Mohamed Boudra 4b867c2f41 Fix mobile tool call expansion while streaming 2026-04-22 20:05:41 +07:00
Mohamed Boudra 368ce0339a Lazy-load GitHub picker queries 2026-04-22 20:00:43 +07:00
Mohamed Boudra 8e63274ce6 fix(server): unify worktree archive paths 2026-04-22 19:59:00 +07:00
Mohamed Boudra d80c524e0e fix(app): restore archive and composer CI checks 2026-04-22 19:16:28 +07:00
Mohamed Boudra 89efb007f9 Fix Codex replacement turn idle flash 2026-04-22 18:54:20 +07:00
Mohamed Boudra 407b4b68b0 Recover branch during rebase 2026-04-22 18:35:01 +07:00
Mohamed Boudra 637109eab0 Fix workspace hover card safe zone 2026-04-22 18:22:20 +07:00
github-actions[bot] 3a0161d051 fix: update lockfile signatures and Nix hash 2026-04-22 11:08:00 +00:00
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
227 changed files with 14987 additions and 2438 deletions
+3
View File
@@ -132,6 +132,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency - name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight run: npm run build --workspace=@getpaseo/highlight
+46 -3
View File
@@ -1,10 +1,53 @@
# Changelog # Changelog
## 0.1.60-beta.1 - 2026-04-20 ## 0.1.60 - 2026-04-22
### Added ### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable. - 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).
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`. - 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.
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
### 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.
- Tool call blocks expand correctly on mobile while an agent is still streaming.
- Timeline no longer stutters when catch-up and projected ranges overlap.
- Codex no longer flashes idle when a replacement turn is in progress.
- Branch state recovers correctly when a rebase is in progress.
- Workspace hover card no longer clips near screen edges.
## 0.1.59 - 2026-04-16 ## 0.1.59 - 2026-04-16
+5
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`. - `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 ### Default ports
+27
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. **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 ### 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." 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."
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw="; npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
# Prevent onnxruntime-node's install script from running during automatic # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+114 -30
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-beta.1", "version": "0.1.60",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.60-beta.1", "version": "0.1.60",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -12771,7 +12771,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -12792,7 +12791,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -12806,7 +12804,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -12821,8 +12818,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "16.3.2", "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": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "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", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -14145,6 +14154,42 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -14994,7 +15039,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -18119,8 +18163,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
@@ -27319,7 +27362,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -28679,6 +28721,18 @@
"node": ">=6" "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": { "node_modules/mimic-response": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -30067,6 +30121,34 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/p-retry": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -37303,16 +37385,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1", "@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60-beta.1", "@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60-beta.1", "@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -37391,6 +37473,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
@@ -37456,11 +37539,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-beta.1", "@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60-beta.1", "@getpaseo/server": "0.1.60",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37501,11 +37584,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.60-beta.1", "version": "0.1.60",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.60-beta.1", "@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60-beta.1", "@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -37539,7 +37622,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-beta.1", "version": "0.1.60",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -37740,7 +37823,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -37766,7 +37849,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -37782,14 +37865,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-beta.1", "@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60-beta.1", "@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68", "@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
@@ -37808,6 +37891,7 @@
"onnxruntime-node": "^1.23.0", "onnxruntime-node": "^1.23.0",
"openai": "^4.20.0", "openai": "^4.20.0",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
"pino": "^10.2.0", "pino": "^10.2.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -38543,7 +38627,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.60-beta.1", "version": "0.1.60",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-beta.1", "version": "0.1.60",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
+26
View File
@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
import { import {
archiveAgentFromDaemon, archiveAgentFromDaemon,
archiveAgentFromSessions, archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
connectArchiveTabDaemonClient, connectArchiveTabDaemonClient,
createIdleAgent, createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible, expectSessionRowVisible,
expectWorkspaceArchiveOutcome, expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden, expectWorkspaceTabHidden,
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
await passivePage.close(); 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);
});
}); });
+45 -2
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import net from "node:net"; import net from "node:net";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
type WaitForServerOptions = { type WaitForServerOptions = {
host?: string; host?: string;
@@ -185,6 +186,17 @@ let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null; let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | 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 = { type OfferPayload = {
v: 2; v: 2;
serverId: string; serverId: string;
@@ -220,6 +232,11 @@ if (args[0] === "pr" && args[1] === "list") {
process.exit(0); 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") { if (args[0] === "issue" && args[1] === "list") {
console.log("[]"); console.log("[]");
process.exit(0); process.exit(0);
@@ -322,12 +339,36 @@ export default async function globalSetup() {
const port = await getAvailablePort(); const port = await getAvailablePort();
let relayPort = 0; let relayPort = 0;
const metroPort = await getAvailablePort(); 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(); fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer(); let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = 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 () => { const cleanup = async () => {
await Promise.all([ await Promise.all([
stopProcess(daemonProcess), stopProcess(daemonProcess),
@@ -337,9 +378,11 @@ export default async function globalSetup() {
daemonProcess = null; daemonProcess = null;
metroProcess = null; metroProcess = null;
relayProcess = null; relayProcess = null;
if (paseoHome) { if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
paseoHome = null; paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
} }
if (fakeGhBinDir) { if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true }); await rm(fakeGhBinDir, { recursive: true, force: true });
+24
View File
@@ -231,6 +231,24 @@ export async function expectWorkspaceArchiveOutcome(
await expectWorkspaceTabVisible(page, input.survivingAgentId); 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> { export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId(); const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); 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 }); 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( export async function archiveAgentFromSessions(
page: Page, page: Page,
input: { agentId: string; title: string }, input: { agentId: string; title: string },
+131
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],
};
}
+237
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, "\\$&");
}
+98
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);
}
}
@@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = {
terminal: { id: string; name: string; cwd: string } | null; terminal: { id: string; name: string; cwd: string } | null;
error: 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( subscribeTerminal(
terminalId: string, terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
+680
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,
);
}
+180 -3
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures"; import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
import { import {
archiveWorkspaceFromDaemon, archiveWorkspaceFromDaemon,
@@ -28,6 +28,108 @@ import {
workspaceLabelFromPath, workspaceLabelFromPath,
} from "./helpers/workspace-ui"; } 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", () => { test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>(); 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, page,
}) => { }) => {
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
await agentTabs.first().click(); await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." }); const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 }); await expect(composer).toBeVisible({ timeout: 30_000 });
} finally { } finally {
await tempRepo.cleanup(); 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 }) => { test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
if (!serverId) { if (!serverId) {
@@ -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();
}
});
});
+64
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();
});
});
@@ -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);
});
});
@@ -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([]);
});
});
});
@@ -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;
}
@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000; const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20; const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150; 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 client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> }; let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string; let workspaceId: string;
@@ -139,6 +139,35 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 }); await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2); 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({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
@@ -19,6 +19,18 @@ function getServerId(): string {
return serverId; 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. */ /** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar( async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page, 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); test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient(); const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", { const repo = await createTempGitRepo("setup-svc-ui-", {
@@ -263,12 +275,15 @@ test.describe("Workspace setup streaming", () => {
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar. await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
// The tab title shows the command, not the script name. 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(); const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 }); await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click(); await terminalTab.click();
// Verify the terminal surface rendered // 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 client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", { const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: { paseoConfig: {
@@ -303,7 +318,7 @@ test.describe("Workspace setup streaming", () => {
}, },
scripts: { scripts: {
editor: { 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"); throw new Error(result.error ?? "Failed to create workspace");
} }
const workspaceDir = result.workspace.workspaceDirectory; const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
await completed; 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 await expect
.poll(async () => { .poll(async () => {
const terminals = await client.listTerminals(workspaceDir); const terminals = await client.listTerminals(workspaceDir);
+71
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
```
@@ -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%"
@@ -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"
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -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"
+187
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"
@@ -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
@@ -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
@@ -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
+6 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.60-beta.1", "version": "0.1.60",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "APP_VARIANT=development expo start", "start": "APP_VARIANT=development expo start",
@@ -19,6 +19,7 @@
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"build": "npm run build:web", "build": "npm run build:web",
@@ -31,9 +32,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1", "@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60-beta.1", "@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60-beta.1", "@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -112,6 +113,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"eas-cli": "^16.24.1", "eas-cli": "^16.24.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
+46 -39
View File
@@ -28,6 +28,7 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings"; import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme"; import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { import {
@@ -77,7 +78,7 @@ import {
import { listenToDesktopEvent } from "@/desktop/electron/events"; import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { import {
buildHostRootRoute, buildHostRootRoute,
mapPathnameToServer, mapPathnameToServer,
@@ -86,6 +87,10 @@ import {
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } 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 { import {
addBrowserActiveWorkspaceLocationListener, addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace, syncNavigationActiveWorkspace,
@@ -124,7 +129,36 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
function PushNotificationRouter() { function PushNotificationRouter() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const lastHandledIdRef = useRef<string | null>(null); 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(() => { useEffect(() => {
if (isWeb) { if (isWeb) {
@@ -145,7 +179,7 @@ function PushNotificationRouter() {
(payload as { data?: unknown }).data !== null (payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data ? (payload as { data: Record<string, unknown> }).data
: undefined; : undefined;
router.push(buildNotificationRoute(data) as any); openNotification(data);
}, },
); );
@@ -165,7 +199,7 @@ function PushNotificationRouter() {
const openFromWebClick = (event: Event) => { const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>; const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault(); event.preventDefault();
router.push(buildNotificationRoute(customEvent.detail?.data) as any); openNotification(customEvent.detail?.data);
}; };
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
@@ -198,7 +232,7 @@ function PushNotificationRouter() {
const data = response.notification.request.content.data as const data = response.notification.request.content.data as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
router.push(buildNotificationRoute(data) as any); openNotification(data);
}; };
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse); const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
@@ -212,7 +246,7 @@ function PushNotificationRouter() {
return () => { return () => {
subscription.remove(); subscription.remove();
}; };
}, [router]); }, [openNotification]);
return null; return null;
} }
@@ -319,7 +353,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
} }
} }
} else { } else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) { if (!cancelled) {
setPhase("online"); setPhase("online");
setError(null); setError(null);
@@ -401,8 +437,6 @@ function AppContainer({
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => { const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); 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. // other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings"); 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({ useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled, enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout, isMobile: isCompactLayout,
@@ -783,8 +788,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>(); const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts(); const hosts = useHosts();
const storeReady = useStoreReady();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null; const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
useEffect(() => { useEffect(() => {
if (!activeServerId || hosts.length === 0) { if (!activeServerId || hosts.length === 0) {
@@ -840,6 +847,7 @@ function RootStack() {
}, },
}} }}
> >
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}> <Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" /> <Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" /> <Stack.Screen name="settings/index" />
@@ -859,7 +867,6 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" /> <Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" /> <Stack.Screen name="settings/hosts/[serverId]" />
</Stack> </Stack>
); );
@@ -15,6 +15,7 @@ import {
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { import {
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
@@ -69,9 +70,11 @@ function clearConsumedOpenIntent(input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
}) { }) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined }); input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl(); if (isWeb) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
stripOpenSearchParamFromBrowserUrl();
}
} }
export default function HostWorkspaceLayout() { export default function HostWorkspaceLayout() {
@@ -86,6 +89,7 @@ function HostWorkspaceLayoutContent() {
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const rootNavigationState = useRootNavigationState(); const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null); const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false); const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
@@ -126,6 +130,9 @@ function HostWorkspaceLayoutContent() {
if (!rootNavigationState?.key) { if (!rootNavigationState?.key) {
return; return;
} }
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) { if (consumedIntentRef.current === consumptionKey) {
@@ -161,9 +168,17 @@ function HostWorkspaceLayoutContent() {
}); });
setIntentConsumed(true); 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; return null;
} }
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
const fileSystemMock = vi.hoisted(() => ({
getInfoAsync: vi.fn(async (uri: string) => {
if (uri.endsWith("/preview-assets/")) {
return { exists: true, isDirectory: true };
}
return { exists: true, isDirectory: false, size: 4 };
}),
makeDirectoryAsync: vi.fn(async () => {}),
writeAsStringAsync: vi.fn(async () => {}),
copyAsync: vi.fn(async () => {}),
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
}));
vi.mock("expo-file-system/legacy", () => ({
cacheDirectory: "file:///cache/",
EncodingType: { Base64: "base64" },
getInfoAsync: fileSystemMock.getInfoAsync,
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
copyAsync: fileSystemMock.copyAsync,
readAsStringAsync: fileSystemMock.readAsStringAsync,
deleteAsync: fileSystemMock.deleteAsync,
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
}));
describe("local file attachment store", () => {
beforeEach(() => {
fileSystemMock.getInfoAsync.mockClear();
fileSystemMock.makeDirectoryAsync.mockClear();
fileSystemMock.writeAsStringAsync.mockClear();
fileSystemMock.copyAsync.mockClear();
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
});
it("writes raw base64 sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
);
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
storageType: "native-file",
storageKey: "/cache/preview-assets/preview_8_test.png",
fileName: "result.png",
byteSize: 4,
});
});
});
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
return; return;
} }
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob); const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, { await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64, encoding: FileSystem.EncodingType.Base64,
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
id: input.id ?? "att_1",
mimeType: input.mimeType ?? "image/png",
storageType: input.storageType ?? "web-indexeddb",
storageKey: input.storageKey ?? "att_1",
fileName: input.fileName,
byteSize: input.byteSize,
createdAt: input.createdAt ?? 1700000000000,
};
}
function createRecordingStore(): AttachmentStore & {
savedSources: SaveAttachmentInput[];
releasedUrls: string[];
} {
const savedSources: SaveAttachmentInput[] = [];
const releasedUrls: string[] = [];
return {
storageType: "web-indexeddb",
savedSources,
releasedUrls,
async save(input) {
savedSources.push(input);
return createAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
byteSize: 4,
});
},
async encodeBase64({ attachment }) {
return `${attachment.id}:base64`;
},
async resolvePreviewUrl({ attachment }) {
return `blob:${attachment.id}`;
},
async releasePreviewUrl({ url }) {
releasedUrls.push(url);
},
async delete() {},
async garbageCollect() {},
};
}
describe("attachment service", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
fileName: "image.png",
byteSize: 4,
createdAt: 1700000000000,
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
},
]);
});
it("keeps provider send output byte-compatible", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
});
+15
View File
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
}); });
} }
export async function persistAttachmentFromBase64(input: {
base64: string;
mimeType?: string;
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "base64", base64: input.base64 },
});
}
export async function persistAttachmentFromFileUri(input: { export async function persistAttachmentFromFileUri(input: {
uri: string; uri: string;
mimeType?: string; mimeType?: string;
+1
View File
@@ -23,6 +23,7 @@ export type ComposerAttachment =
| { kind: "github_pr"; item: GitHubSearchItem }; | { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource = export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "blob"; blob: Blob } | { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string } | { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string }; | { kind: "file_uri"; uri: string };
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils"; import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
describe("pathToFileUri", () => { describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => { it("converts POSIX absolute paths to file URIs", () => {
@@ -22,3 +22,35 @@ describe("pathToFileUri", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path"); expect(pathToFileUri("relative/path")).toBe("relative/path");
}); });
}); });
describe("parseDataUrl", () => {
it("accepts base64 data URLs with media-type parameters", () => {
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
mimeType: "image/png",
base64: "AAECAw==",
});
});
it("rejects non-base64 data URLs", () => {
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
"Attachment data URL is not base64 encoded.",
);
});
});
describe("parseImageDataUrl", () => {
it("returns a compact cache key for image data URLs", () => {
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
expect(parseImageDataUrl(dataUrl)).toMatchObject({
mimeType: "image/png",
base64: "a".repeat(512),
});
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
});
it("ignores non-image data URLs", () => {
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
});
});
+63 -2
View File
@@ -14,11 +14,17 @@ export function normalizeMimeType(input: string | undefined | null): string {
} }
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } { export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl); const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
if (!match) { if (!match) {
throw new Error("Malformed data URL for attachment."); throw new Error("Malformed data URL for attachment.");
} }
const [, mimeTypeRaw, base64] = match; const metadata = match[1] ?? "";
const base64 = match[2]?.replace(/\s/g, "");
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
if (!isBase64) {
throw new Error("Attachment data URL is not base64 encoded.");
}
if (!base64) { if (!base64) {
throw new Error("Attachment data URL is missing base64 payload."); throw new Error("Attachment data URL is missing base64 payload.");
} }
@@ -28,6 +34,61 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
}; };
} }
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
export function parseImageDataUrl(
uri: string,
): { mimeType: string; base64: string; cacheKey: string } | null {
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
return null;
}
try {
const parsed = parseDataUrl(uri);
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
}
}
export function createImageSourceCacheKey(source: string): string {
return parseImageDataUrl(source)?.cacheKey ?? source;
}
export function getFileNameFromPath(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
const fileName = normalized.split("/").pop()?.trim();
return fileName || null;
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
}): string {
const path = input.path?.trim() ?? "";
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> { export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => { return await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -74,6 +74,15 @@ function runTx<T>(
}); });
} }
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> { async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source; const source = input.source;
if (source.kind === "blob") { if (source.kind === "blob") {
@@ -96,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
}; };
} }
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri); const response = await fetch(source.uri);
const blob = await response.blob(); const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type); const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
+78 -15
View File
@@ -21,6 +21,8 @@ import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
interface AgentListProps { interface AgentListProps {
agents: AggregatedAgent[]; agents: AggregatedAgent[];
@@ -37,6 +39,62 @@ type FlatListItem =
| { type: "header"; key: string; title: string } | { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: AggregatedAgent }; | { 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 { function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date(); const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -223,6 +281,7 @@ export function AgentList({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent();
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -251,6 +310,7 @@ export function AgentList({
return; return;
} }
rememberArchivedAgentDetail(agent);
const route = prepareWorkspaceTab({ const route = prepareWorkspaceTab({
serverId, serverId,
workspaceId, workspaceId,
@@ -262,20 +322,23 @@ export function AgentList({
[isActionSheetVisible, onAgentSelect], [isActionSheetVisible, onAgentSelect],
); );
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { const handleAgentLongPress = useCallback(
const isRunning = agent.status === "running" || agent.status === "initializing"; (agent: AggregatedAgent) => {
if (isRunning) { const isRunning = agent.status === "running" || agent.status === "initializing";
setActionAgent(agent); if (isRunning) {
return; setActionAgent(agent);
} return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) { if (!client) {
setActionAgent(agent); setActionAgent(agent);
return; return;
} }
void client.archiveAgent(agent.id); void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {});
}, []); },
[archiveAgent],
);
const handleCloseActionSheet = useCallback(() => { const handleCloseActionSheet = useCallback(() => {
setActionAgent(null); setActionAgent(null);
@@ -286,9 +349,9 @@ export function AgentList({
return; return;
} }
// Timeout errors are swallowed — the daemon will still process the archive // Timeout errors are swallowed — the daemon will still process the archive
void actionClient.archiveAgent(actionAgent.id).catch(() => {}); void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {});
setActionAgent(null); setActionAgent(null);
}, [actionAgent, actionClient]); }, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => { const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
@@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
entries: snapshotEntries, entries: snapshotEntries,
isLoading: snapshotIsLoading, isLoading: snapshotIsLoading,
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd); } = useProvidersSnapshot(serverId);
const snapshotSelectedEntry = useMemo(() => { const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) { if (!snapshotEntries || !agent?.provider) {
@@ -79,6 +79,43 @@ import { isWeb } from "@/constants/platform";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; 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 { export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void; prepareForViewportChange(): void;
@@ -249,6 +286,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
} }
const tightGap = theme.spacing[1]; // 4px const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px const looseGap = theme.spacing[4]; // 16px
const getGapBetween = useCallback( const getGapBetween = useCallback(
@@ -261,26 +299,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return tightGap; return tightGap;
} }
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return tightGap; return 0;
} }
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap; return looseGap;
} }
if ( if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
return tightGap; return tightGap;
} }
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap; return looseGap;
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return assistantBlockGap;
} }
return looseGap; return looseGap;
}, },
[looseGap, tightGap], [assistantBlockGap, looseGap, tightGap],
); );
const renderStreamItemContent = useCallback( 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 ( return (
<AssistantMessage <AssistantMessage
message={item.text} message={item.text}
@@ -344,8 +399,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
serverId={serverId} serverId={serverId}
client={client} client={client}
spacing={spacing}
/> />
); );
}
case "thought": { case "thought": {
const nextItem = getStreamNeighborItem({ const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
@@ -485,21 +542,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const showWorkingIndicator = agent.status === "running"; const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => { const pendingPermissionsNode = useMemo(
const pendingPermissionsNode = () =>
pendingPermissionItems.length > 0 ? ( pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}> <View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => ( {pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} /> <PermissionRequestCard key={permission.key} permission={permission} client={client} />
))} ))}
</View> </View>
) : null; ) : null,
const workingIndicatorNode = showWorkingIndicator ? ( [client, pendingPermissionItems],
<View style={stylesheet.bottomBarWrapper}> );
<WorkingIndicator /> const workingIndicatorNode = useMemo(
</View> () =>
) : null; showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
) : null,
[showWorkingIndicator],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return { return {
...baseRenderModel, ...baseRenderModel,
boundary: { boundary: {
@@ -514,7 +577,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workingIndicator: workingIndicatorNode, workingIndicator: workingIndicatorNode,
}, },
}; };
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]); }, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
const listEmptyComponent = useMemo(() => { const listEmptyComponent = useMemo(() => {
if ( if (
@@ -579,7 +642,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<View <View
style={[ style={[
stylesheet.listHeaderContent, stylesheet.listHeaderContent,
boundary.hasLiveHead ? { paddingTop: tightGap } : null, boundary.hasLiveHead
? streamRenderStrategy.getFlatListInverted()
? { paddingBottom: looseGap }
: { paddingTop: looseGap }
: null,
]} ]}
> >
{auxiliary.pendingPermissions} {auxiliary.pendingPermissions}
@@ -587,7 +654,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</View> </View>
</View> </View>
); );
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]); }, [
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
looseGap,
streamRenderStrategy,
]);
const renderers = useMemo<StreamSegmentRenderers>( const renderers = useMemo<StreamSegmentRenderers>(
() => ({ () => ({
@@ -996,6 +1069,7 @@ const stylesheet = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "flex-start", justifyContent: "flex-start",
marginTop: theme.spacing[4],
paddingLeft: 3, paddingLeft: 3,
paddingRight: 3, paddingRight: 3,
paddingTop: theme.spacing[3], paddingTop: theme.spacing[3],
+80 -2
View File
@@ -9,6 +9,8 @@ import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer"; import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments"; import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
const { const {
theme, theme,
imageMetadata, imageMetadata,
@@ -325,7 +327,9 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
})); }));
vi.mock("@/hooks/use-keyboard-action-handler", () => ({ vi.mock("@/hooks/use-keyboard-action-handler", () => ({
useKeyboardActionHandler: () => {}, useKeyboardActionHandler: (input: unknown) => {
keyboardActionHandlerMock(input);
},
})); }));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({ vi.mock("@/hooks/use-keyboard-shift-style", () => ({
@@ -340,7 +344,7 @@ vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }), useToast: () => ({ error: vi.fn() }),
})); }));
vi.mock("@/utils/scroll-jank-investigation", () => ({ vi.mock("@/utils/scroll-jank", () => ({
markScrollInvestigationRender: markScrollInvestigationRenderMock, markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(), markScrollInvestigationEvent: vi.fn(),
})); }));
@@ -575,6 +579,10 @@ beforeEach(() => {
vi.stubGlobal("Node", dom.window.Node); vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator); vi.stubGlobal("navigator", dom.window.navigator);
vi.stubGlobal("Blob", dom.window.Blob); vi.stubGlobal("Blob", dom.window.Blob);
Object.assign(dom.window.HTMLElement.prototype, {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -593,6 +601,7 @@ beforeEach(() => {
setAgentStreamTailMock.mockClear(); setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear(); setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear(); setQueuedMessagesMock.mockClear();
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset(); agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready"); agentDirectoryStatusMock.mockReturnValue("ready");
mockSessionState.sessions.server.serverInfo = { 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.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map(); mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = 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-"]'); 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 { function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter( return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent", ([componentId]) => componentId === "MessageInput:server:agent",
).length; ).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", () => { describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => { it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer(); renderComposer();
@@ -773,6 +834,23 @@ describe("Composer attachments", () => {
expect(combobox.dataset.anchor).toBe("attached"); expect(combobox.dataset.anchor).toBe("attached");
}); });
it("lazily searches GitHub only after the GitHub combobox opens", async () => {
renderComposer();
await flushAsyncWork();
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
await findByTestId("composer-github-combobox");
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
limit: 20,
});
});
it("closes the GitHub combobox after selecting an item", async () => { it("closes the GitHub combobox after selecting an item", async () => {
renderComposer(); renderComposer();
+19 -21
View File
@@ -54,7 +54,7 @@ import {
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
} from "@/attachments/service"; } from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls"; 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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -176,7 +176,7 @@ export function Composer({
toastErrorRef.current = toast.error; toastErrorRef.current = toast.error;
const voice = useVoiceOptional(); const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle"); const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel"); const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({ const isDictationReady = useIsDictationReady({
serverId, serverId,
isConnected, isConnected,
@@ -525,6 +525,15 @@ export function Composer({
} }
switch (action.id) { 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": case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false; return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm": case "message-input.dictation-confirm":
@@ -560,12 +569,13 @@ export function Composer({
return false; return false;
} }
}, },
[isPaneFocused], [handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused],
); );
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current, handlerId: keyboardHandlerIdRef.current,
actions: [ actions: [
"agent.interrupt",
"message-input.focus", "message-input.focus",
"message-input.send", "message-input.send",
"message-input.dictation-toggle", "message-input.dictation-toggle",
@@ -640,24 +650,12 @@ export function Composer({
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; 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( const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => { (event: { key: string; preventDefault: () => void }) => {
if (
event.key === "Escape" &&
isAgentRunning &&
!hasSendableContent &&
!isCancellingAgent &&
isConnected
) {
event.preventDefault();
handleCancelAgent();
return true;
}
return autocompleteOnKeyPressRef.current(event); return autocompleteOnKeyPressRef.current(event);
}, },
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent], [],
); );
const cancelButton = useMemo( const cancelButton = useMemo(
@@ -683,16 +681,16 @@ export function Composer({
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text> <Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? ( {agentInterruptKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} /> <Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null} ) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null, ) : null,
[ [
agentInterruptKeys,
buttonIconSize, buttonIconSize,
dictationCancelKeys,
handleCancelAgent, handleCancelAgent,
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
@@ -792,7 +790,7 @@ export function Composer({
limit: 20, limit: 20,
}); });
}, },
enabled: isConnected && !!client && cwd.trim().length > 0, enabled: isGithubPickerOpen && isConnected && !!client && cwd.trim().length > 0,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -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");
});
});
@@ -159,12 +159,16 @@ export function DraggableList<T>({
[data, onDragBegin], [data, onDragBegin],
); );
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); clearDragState();
setDragItems(null);
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.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)); const ids = items.map((item, index) => keyExtractor(item, index));
@@ -207,6 +211,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter} collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]} modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext items={ids} strategy={verticalListSortingStrategy}> <SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -237,6 +242,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter} collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]} modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext items={ids} strategy={verticalListSortingStrategy}> <SortableContext items={ids} strategy={verticalListSortingStrategy}>
+51 -3
View File
@@ -25,6 +25,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64 } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
@@ -40,6 +44,7 @@ interface FilePreviewBodyProps {
showDesktopWebScrollbar: boolean; showDesktopWebScrollbar: boolean;
isMobile: boolean; isMobile: boolean;
filePath: string; filePath: string;
imagePreviewUri: string | null;
} }
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
@@ -60,6 +65,32 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`;
} }
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file || file.kind !== "image" || !file.content) {
return { file, imageAttachment: null };
}
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path),
});
return {
file: imageFile,
imageAttachment,
};
}
const CodeLine = React.memo(function CodeLine({ const CodeLine = React.memo(function CodeLine({
tokens, tokens,
lineNumber, lineNumber,
@@ -116,6 +147,7 @@ function FilePreviewBody({
showDesktopWebScrollbar, showDesktopWebScrollbar,
isMobile, isMobile,
filePath, filePath,
imagePreviewUri,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark"; const isDark = theme.colorScheme === "dark";
@@ -230,7 +262,16 @@ function FilePreviewBody({
); );
} }
if (preview.kind === "image" && preview.content) { if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return ( return (
<View style={styles.previewScrollContainer}> <View style={styles.previewScrollContainer}>
<RNScrollView <RNScrollView
@@ -245,7 +286,7 @@ function FilePreviewBody({
> >
<RNImage <RNImage
source={{ source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`, uri: imagePreviewUri,
}} }}
style={styles.previewImage} style={styles.previewImage}
resizeMode="contain" resizeMode="contain"
@@ -292,11 +333,17 @@ export function FilePane({
normalizedFilePath, normalizedFilePath,
"file", "file",
); );
return { file: payload.file ?? null, error: payload.error ?? null }; const preview = await createFilePanePreview(payload.file ?? null);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: payload.error ?? null,
};
}, },
staleTime: 5_000, staleTime: 5_000,
refetchOnMount: true, refetchOnMount: true,
}); });
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
return ( return (
<View style={styles.container} testID="workspace-file-pane"> <View style={styles.container} testID="workspace-file-pane">
@@ -312,6 +359,7 @@ export function FilePane({
showDesktopWebScrollbar={showDesktopWebScrollbar} showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile} isMobile={isMobile}
filePath={filePath} filePath={filePath}
imagePreviewUri={imagePreviewUri}
/> />
</View> </View>
); );
@@ -18,6 +18,7 @@ interface ScreenHeaderProps {
leftStyle?: StyleProp<ViewStyle>; leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>; rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean; borderless?: boolean;
windowControlsPaddingRole?: "header" | "detailHeader";
onRowLayout?: (event: LayoutChangeEvent) => void; onRowLayout?: (event: LayoutChangeEvent) => void;
} }
@@ -31,12 +32,13 @@ export function ScreenHeader({
leftStyle, leftStyle,
rightStyle, rightStyle,
borderless, borderless,
windowControlsPaddingRole = "header",
onRowLayout, onRowLayout,
}: ScreenHeaderProps) { }: ScreenHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); 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 // 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 topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = theme.spacing[2];
@@ -1,13 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout"; import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) { export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const storeReady = useStoreReady(); const storeReady = useStoreReady();
const bootstrapState = useHostRuntimeBootstrapState(); const bootstrapState = useHostRuntimeBootstrapState();
const isDesktop = shouldUseDesktopDaemon();
if (!storeReady) { if (!storeReady) {
return <StartupSplashScreen bootstrapState={bootstrapState} />; return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
} }
return <>{children}</>; return <>{children}</>;
+5 -1
View File
@@ -379,6 +379,10 @@ function MobileSidebar({
windowWidth, windowWidth,
]); ]);
const handleWorkspacePress = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
const closeGesture = useMemo( const closeGesture = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
@@ -513,7 +517,7 @@ function MobileSidebar({
projects={projects} projects={projects}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onWorkspacePress={() => closeToAgent()} onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject} onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef} parentGestureRef={closeGestureRef}
/> />
@@ -51,10 +51,7 @@ import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { import { markScrollInvestigationEvent, markScrollInvestigationRender } from "@/utils/scroll-jank";
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror"; import { useComposerHeightMirror } from "./composer-height-mirror";
@@ -307,8 +304,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (action === "dictation-cancel") { if (action === "dictation-cancel") {
if (isDictatingRef.current) { if (isDictatingRef.current) {
cancelDictation(); cancelDictation();
return true;
} }
return true; return false;
} }
if (action === "dictation-toggle") { if (action === "dictation-toggle") {
+184 -120
View File
@@ -71,18 +71,24 @@ import {
} from "@/utils/inline-path"; } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url"; 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 { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { import {
getAssistantImageMetadata, getAssistantImageMetadata,
setAssistantImageMetadata, setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata"; } from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
export type { InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card"; import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
@@ -283,6 +289,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
content: { content: {
alignItems: "flex-end", alignItems: "flex-end",
maxWidth: "100%", maxWidth: "100%",
cursor: "auto",
}, },
containerSpacing: { containerSpacing: {
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
@@ -432,6 +439,7 @@ interface AssistantMessageProps {
serverId?: string; serverId?: string;
client?: DaemonClient | null; client?: DaemonClient | null;
disableOuterSpacing?: boolean; disableOuterSpacing?: boolean;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
} }
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -439,6 +447,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
}, },
containerCompactTop: {
paddingTop: 0,
},
containerCompactBottom: {
paddingBottom: 0,
},
containerSpacing: { containerSpacing: {
marginBottom: theme.spacing[4], marginBottom: theme.spacing[4],
}, },
@@ -587,6 +601,7 @@ function AssistantMarkdownImage({
() => resolveAssistantImageSource({ source, workspaceRoot }), () => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot], [source, workspaceRoot],
); );
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>( const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({ () => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0, marginTop: hasLeadingContent ? theme.spacing[4] : 0,
@@ -613,16 +628,44 @@ function AssistantMarkdownImage({
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) { const file = payload.file;
if (!file || file.kind !== "image" || !file.content) {
throw new Error("Image preview unavailable."); throw new Error("Image preview unavailable.");
} }
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`; return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
});
const dataImageQuery = useQuery({
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
enabled: dataImage !== null,
staleTime: 30_000,
queryFn: async () => {
if (!dataImage) {
return null;
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId(dataImage),
dataUrl: source,
mimeType: dataImage.mimeType,
});
}, },
}); });
const directUri = resolution?.kind === "direct" ? resolution.uri : null; const fileAssetUri = useAttachmentPreviewUrl(query.data);
const resolvedUri = directUri ?? query.data ?? null; const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
if (resolvedUri) { if (resolvedUri) {
return ( return (
@@ -637,7 +680,7 @@ function AssistantMarkdownImage({
); );
} }
if (query.isLoading) { if (query.isLoading || dataImageQuery.isLoading) {
return ( return (
<View <View
style={[ style={[
@@ -660,7 +703,11 @@ function AssistantMarkdownImage({
]} ]}
> >
<Text style={assistantMessageStylesheet.imageErrorText}> <Text style={assistantMessageStylesheet.imageErrorText}>
{query.error instanceof Error ? query.error.message : "Unable to load image preview."} {query.error instanceof Error
? query.error.message
: dataImageQuery.error instanceof Error
? dataImageQuery.error.message
: "Unable to load image preview."}
</Text> </Text>
</View> </View>
); );
@@ -951,6 +998,116 @@ 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
pointerEvents="none"
style={nativeShimmerTrackStyle}
maskElement={
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
</View>
}
>
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
<Animated.View pointerEvents="none" 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 { interface MemoizedMarkdownBlockProps {
text: string; text: string;
styles: ReturnType<typeof createMarkdownStyles>; styles: ReturnType<typeof createMarkdownStyles>;
@@ -988,9 +1145,12 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId, serverId,
client, client,
disableOuterSpacing, disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -1191,6 +1351,10 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message" testID="assistant-message"
style={[ style={[
assistantMessageStylesheet.container, assistantMessageStylesheet.container,
(spacing === "compactTop" || spacing === "compactBoth") &&
assistantMessageStylesheet.containerCompactTop,
(spacing === "compactBottom" || spacing === "compactBoth") &&
assistantMessageStylesheet.containerCompactBottom,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing, !resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]} ]}
> >
@@ -1669,7 +1833,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [labelWidth, setLabelWidth] = useState(0); const [labelWidth, setLabelWidth] = useState(0);
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0); const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
const [secondaryWidth, setSecondaryWidth] = useState(0); const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0); const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
@@ -1737,28 +1900,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
ensureWebToolCallShimmerKeyframes(); ensureWebToolCallShimmerKeyframes();
}, [isWebShimmer]); }, [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(() => { useEffect(() => {
if (isNative || !isExpanded || !hasDetailContent) { if (isNative || !isExpanded || !hasDetailContent) {
return; return;
@@ -1783,10 +1924,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
}; };
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]); }, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
const shimmerGradient = 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%)"; "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 +2023,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
[shimmerSecondaryStyle], [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( const chevronStyle = useMemo(
() => [ () => [
expandableBadgeStylesheet.chevron, expandableBadgeStylesheet.chevron,
@@ -2002,52 +2104,15 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View> </View>
) : null} ) : null}
{isNativeShimmer ? ( {isNativeShimmer ? (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none"> <NativeExpandableBadgeShimmer
<MaskedView label={label}
style={nativeShimmerTrackStyle} secondaryLabel={secondaryLabel}
maskElement={ rowWidth={labelRowWidth}
<View style={nativeShimmerMaskStyle}> rowHeight={labelRowHeight}
<Text style={nativeLabelMaskStyle} numberOfLines={1}> peakWidth={nativeShimmerPeakWidth}
{label} durationSeconds={shimmerDuration}
</Text> gradientId={nativeGradientIdRef.current}
{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>
) : null} ) : null}
</View> </View>
{isInteractive && isHovered ? ( {isInteractive && isHovered ? (
@@ -2080,10 +2145,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.testID !== next.testID) return false; if (previous.testID !== next.testID) return false;
if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false; if (previous.onToggle !== next.onToggle) return false;
if (previous.isExpanded && previous.renderDetails !== next.renderDetails) { if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
return false; if (previous.renderDetails !== next.renderDetails) return false;
}
return true; return true;
} }
@@ -62,6 +62,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}; };
function workspace(input: { function workspace(input: {
@@ -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");
});
});
@@ -155,12 +155,16 @@ export function SortableInlineList<T>({
[data, disabled, onDragBegin], [data, disabled, onDragBegin],
); );
const clearDragState = useCallback(() => {
setActiveId(null);
setDragItems(null);
}, []);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); clearDragState();
setDragItems(null);
if (disabled) { if (disabled) {
return; 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)); const ids = items.map((item, index) => keyExtractor(item, index));
@@ -213,6 +217,7 @@ export function SortableInlineList<T>({
collisionDetection={closestCenter} collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]} modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{renderedItems} {renderedItems}
@@ -8,6 +8,7 @@ import {
type NativeSyntheticEvent, type NativeSyntheticEvent,
} from "react-native"; } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller"; import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { import {
@@ -37,6 +38,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle, baseListContentContainerStyle,
strategy, strategy,
} = props; } = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const streamViewportMetricsRef = useRef({ const streamViewportMetricsRef = useRef({
containerKey: "native-virtualized", containerKey: "native-virtualized",
@@ -190,105 +192,91 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}; };
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const handleScroll = useCallback( const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
(event: NativeSyntheticEvent<NativeScrollEvent>) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current;
const previousOffsetY = scrollOffsetYRef.current; scrollOffsetYRef.current = contentOffset.y;
scrollOffsetYRef.current = contentOffset.y; streamViewportMetricsRef.current = {
streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height),
contentHeight: Math.max(0, contentSize.height), viewportWidth: Math.max(0, layoutMeasurement.width),
viewportWidth: Math.max(0, layoutMeasurement.width), viewportHeight: Math.max(0, layoutMeasurement.height),
viewportHeight: Math.max(0, layoutMeasurement.height), containerKey: "native-virtualized",
containerKey: "native-virtualized", offsetY: contentOffset.y,
offsetY: contentOffset.y, viewportMeasuredForKey: "native-virtualized",
viewportMeasuredForKey: "native-virtualized", contentMeasuredForKey: "native-virtualized",
contentMeasuredForKey: "native-virtualized", };
};
const nearBottom = isNearBottomForStreamRenderStrategy({ const nearBottom = isNearBottomForStreamRenderStrategy({
strategy, strategy,
offsetY: contentOffset.y, offsetY: contentOffset.y,
threshold: 32, threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight, contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight, 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) { const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
programmaticScrollEventBudgetRef.current -= 1; const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
} else { const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
programmaticScrollEventBudgetRef.current = 0; const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
bottomAnchorController.handleScrollNearBottomChange({ const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
nextIsNearBottom: nearBottom, const viewportChanged =
scrollDelta: contentOffset.y - previousOffsetY, (previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
}); (previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
} streamViewportMetricsRef.current = {
}, ...streamViewportMetricsRef.current,
[bottomAnchorController, onNearBottomChange, strategy], containerKey: "native-virtualized",
); viewportWidth,
viewportHeight,
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
});
const handleListLayout = useCallback( const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
(event: LayoutChangeEvent) => { const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; const nextContentHeight = Math.max(0, height);
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; streamViewportMetricsRef.current = {
const viewportWidth = Math.max(0, event.nativeEvent.layout.width); ...streamViewportMetricsRef.current,
const viewportHeight = Math.max(0, event.nativeEvent.layout.height); containerKey: "native-virtualized",
const viewportChanged = contentHeight: nextContentHeight,
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) || contentMeasuredForKey: "native-virtualized",
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight); };
streamViewportMetricsRef.current = { bottomAnchorController.handleContentSizeChange({
...streamViewportMetricsRef.current, previousContentHeight,
containerKey: "native-virtualized", contentHeight: nextContentHeight,
viewportWidth, });
viewportHeight, });
viewportMeasuredForKey: "native-virtualized",
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
},
[bottomAnchorController, markNativeViewportSettling],
);
const handleContentSizeChange = useCallback( const renderItem = useStableEvent(({ item, index }: ListRenderItemInfo<StreamItem>) => {
(_width: number, height: number) => { const rendered = renderHistoryMountedRow(item, index, historyRows);
const previousContentHeight = streamViewportMetricsRef.current.contentHeight; return rendered ? <Fragment>{rendered}</Fragment> : null;
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 liveHeaderContent = useMemo(() => { const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => ( const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}> <Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
</Fragment>
)); ));
const liveAuxiliary = renderers.renderLiveAuxiliary(); const liveAuxiliary = renderLiveAuxiliary();
if ( if (
liveHeadRows.length === 0 && liveHeadRows.length === 0 &&
!liveAuxiliary && !liveAuxiliary &&
@@ -303,7 +291,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
{liveAuxiliary} {liveAuxiliary}
</Fragment> </Fragment>
); );
}, [boundary, listEmptyComponent, renderers, segments.liveHead]); }, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
return ( return (
<FlatList <FlatList
@@ -321,8 +309,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION} maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={12} initialNumToRender={40}
windowSize={10} maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
windowSize={21}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled} scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator showsVerticalScrollIndicator
inverted inverted
@@ -8,6 +8,7 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus"; import { focusWithRetries } from "../utils/web-focus";
import { import {
computeScrollOffsetFromDragDelta, computeScrollOffsetFromDragDelta,
@@ -325,6 +326,7 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onOpenExternalUrl: openExternalUrl,
}, },
}); });
runtime.setPendingModifiers({ pendingModifiers }); runtime.setPendingModifiers({ pendingModifiers });
@@ -350,6 +352,7 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onOpenExternalUrl: openExternalUrl,
}, },
}); });
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]); }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
+5 -95
View File
@@ -5,12 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal"; import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal"; import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -94,39 +89,6 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: { actionTextPrimary: {
color: theme.colors.accentForeground, 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: { setupLink: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", 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 { export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void; 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 ( return (
<View style={styles.root}> <View style={styles.root}>
@@ -322,11 +240,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}> <View style={styles.content}>
<PaseoLogo size={96} /> <PaseoLogo size={96} />
<View style={styles.copyBlock}> <View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text> {isConnectingToSavedHosts ? (
{showHostList ? ( <Text style={styles.subtitle}>Connecting…</Text>
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
) : ( ) : (
<> <>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text> <Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? ( {isNative ? (
<Pressable <Pressable
@@ -365,14 +283,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
})} })}
</View> </View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -132,7 +132,8 @@ function WorkspaceHoverCardDesktop({
const handleTriggerLeave = useCallback(() => { const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false; triggerHoveredRef.current = false;
}, []); scheduleClose();
}, [scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between // While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering // 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. // When content becomes available while trigger is already hovered, open the card.
useEffect(() => { useEffect(() => {
if (!hasContent || isDragging) return; if (!hasContent) {
clearGraceTimer();
setOpen(false);
return;
}
if (isDragging) return;
if (triggerHoveredRef.current) { if (triggerHoveredRef.current) {
setOpen(true); setOpen(true);
} }
}, [hasContent, isDragging]); }, [clearGraceTimer, hasContent, isDragging]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@@ -32,6 +32,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}; };
describe("patchWorkspaceScripts", () => { describe("patchWorkspaceScripts", () => {
+12 -85
View File
@@ -8,8 +8,8 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream"; import { generateMessageId, type StreamItem } from "@/types/stream";
import { import {
createSessionAgentStreamReducerQueue,
processTimelineResponse, processTimelineResponse,
processAgentStreamEvent,
} from "@/contexts/session-stream-reducers"; } from "@/contexts/session-stream-reducers";
import type { import type {
ActivityLogPayload, ActivityLogPayload,
@@ -1049,6 +1049,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyAgentUpdatePayload(update); applyAgentUpdatePayload(update);
}); });
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
requestCanonicalCatchUp,
});
const unsubAgentStream = client.on("agent_stream", (message) => { const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return; if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload; const { agentId, event, timestamp, seq, epoch } = message.payload;
@@ -1075,96 +1083,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
} }
// Read current store state agentStreamReducerQueue.enqueue(agentId, {
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({
event: streamEvent, event: streamEvent,
seq, seq,
epoch, epoch,
currentTail,
currentHead,
currentCursor,
currentAgent,
timestamp: parsedTimestamp, 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 // NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt // cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes. // 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) => { const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
if (message.type !== "fetch_agent_timeline_response") return; if (message.type !== "fetch_agent_timeline_response") return;
agentStreamReducerQueue.flushAgent(message.payload.agentId);
applyTimelineResponse(message.payload); applyTimelineResponse(message.payload);
}); });
@@ -1572,6 +1498,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubVoiceInputState(); unsubVoiceInputState();
unsubAgentDeleted(); unsubAgentDeleted();
unsubAgentArchived(); unsubAgentArchived();
agentStreamReducerQueue.dispose({ flush: true });
}; };
}, [ }, [
client, client,
@@ -13,10 +13,17 @@ const turnFailedEvent: AgentStreamEventPayload = {
error: "failed", error: "failed",
}; };
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
describe("session stream lifecycle helpers", () => { describe("session stream lifecycle helpers", () => {
it("derives optimistic terminal lifecycle only when current status is running", () => { it("derives optimistic terminal lifecycle only when current status is running", () => {
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle"); expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error"); expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null); expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null); expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
}); });
@@ -10,10 +10,13 @@ export function deriveOptimisticLifecycleStatus(
} }
switch (event.type) { switch (event.type) {
case "turn_completed": case "turn_completed":
case "turn_canceled":
return "idle"; return "idle";
case "turn_failed": case "turn_failed":
return "error"; return "error";
case "turn_canceled":
// A canceled turn can be either a final user cancel or an interrupt before
// a replacement turn starts. The daemon snapshot is authoritative here.
return null;
default: default:
return null; return null;
} }
@@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import {
createAgentStreamReducerQueue,
processTimelineResponse, processTimelineResponse,
processAgentStreamEvent, processAgentStreamEvent,
processAgentStreamEvents,
type ProcessTimelineResponseInput, type ProcessTimelineResponseInput,
type ProcessAgentStreamEventInput, type ProcessAgentStreamEventInput,
type AgentStreamReducerEvent,
type TimelineCursor, type TimelineCursor,
} from "./session-stream-reducers"; } from "./session-stream-reducers";
@@ -16,6 +19,7 @@ import {
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") { function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
return { return {
seqStart: seq, seqStart: seq,
seqEnd: seq,
provider: "claude", provider: "claude",
item: { type, text }, item: { type, text },
timestamp: new Date(1000 + seq).toISOString(), timestamp: new Date(1000 + seq).toISOString(),
@@ -41,6 +45,36 @@ function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
} as 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 = { const baseTimelineInput: ProcessTimelineResponseInput = {
payload: { payload: {
agentId: "agent-1", agentId: "agent-1",
@@ -285,6 +319,39 @@ describe("processTimelineResponse", () => {
expect(result.cursorChanged).toBe(false); expect(result.cursorChanged).toBe(false);
}); });
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [
{
...makeTimelineEntry(4, "merged assistant message"),
seqEnd: 8,
},
],
},
});
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: {
epoch: "epoch-1",
endSeq: 5,
},
});
});
it("drops entries with epoch mismatch", () => { it("drops entries with epoch mismatch", () => {
const existingCursor: TimelineCursor = { const existingCursor: TimelineCursor = {
epoch: "epoch-1", epoch: "epoch-1",
@@ -569,6 +636,28 @@ describe("processAgentStreamEvent", () => {
expect(result.agent!.status).toBe("error"); expect(result.agent!.status).toBe("error");
}); });
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCanceledEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when status is not running", () => { it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = { const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed", type: "turn_completed",
@@ -647,3 +736,270 @@ describe("processAgentStreamEvent", () => {
expect(result.agent).toBe(null); 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);
});
});
@@ -1,5 +1,7 @@
import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; 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 type { StreamItem } from "@/types/stream";
import { import {
applyStreamEvent, applyStreamEvent,
@@ -17,6 +19,8 @@ import {
} from "@/contexts/session-timeline-bootstrap-policy"; } from "@/contexts/session-timeline-bootstrap-policy";
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle"; import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared cursor type // Shared cursor type
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,6 +53,7 @@ type InitRequestDirection = "tail" | "after";
type TimelineResponseEntry = { type TimelineResponseEntry = {
seqStart: number; seqStart: number;
seqEnd: number;
provider: string; provider: string;
item: Record<string, unknown>; item: Record<string, unknown>;
timestamp: string; timestamp: string;
@@ -118,6 +123,7 @@ export function processTimelineResponse(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({ const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart, seq: entry.seqStart,
seqEnd: entry.seqEnd,
event: { event: {
type: "timeline", type: "timeline",
provider: entry.provider, provider: entry.provider,
@@ -196,7 +202,14 @@ export function processTimelineResponse(
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null; gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break; break;
} }
if (decision === "drop_stale" || decision === "drop_epoch") { if (decision === "drop_stale") {
if (cursor && unit.seqEnd > cursor.endSeq) {
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
break;
}
continue;
}
if (decision === "drop_epoch") {
continue; continue;
} }
@@ -205,7 +218,7 @@ export function processTimelineResponse(
cursor = { cursor = {
epoch: payload.epoch, epoch: payload.epoch,
startSeq: unit.seq, startSeq: unit.seq,
endSeq: unit.seq, endSeq: unit.seqEnd,
}; };
continue; continue;
} }
@@ -214,7 +227,7 @@ export function processTimelineResponse(
} }
cursor = { cursor = {
...cursor, ...cursor,
endSeq: unit.seq, endSeq: unit.seqEnd,
}; };
} }
@@ -324,6 +337,62 @@ export interface ProcessAgentStreamEventOutput {
sideEffects: AgentStreamReducerSideEffect[]; 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( export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput, input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput { ): ProcessAgentStreamEventOutput {
@@ -430,3 +499,255 @@ export function processAgentStreamEvent(
sideEffects, 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,
});
}
@@ -86,6 +86,33 @@ describe("desktop attachment store", () => {
}); });
}); });
it("saves raw base64 sources via desktop filesystem writes", async () => {
const store = createDesktopAttachmentStore();
const attachment = await store.save({
id: "att_base64",
mimeType: "image/png",
fileName: "inline.png",
source: {
kind: "base64",
base64: "AAECAw==",
},
});
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
attachmentId: "att_base64",
base64: "AAECAw==",
extension: ".png",
});
expect(attachment).toMatchObject({
id: "att_base64",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/managed/att_2.png",
fileName: "inline.png",
byteSize: 4,
});
});
it("delegates encode/preview/delete/gc to desktop command path", async () => { it("delegates encode/preview/delete/gc to desktop command path", async () => {
const store = createDesktopAttachmentStore(); const store = createDesktopAttachmentStore();
const attachment = { const attachment = {
@@ -166,6 +166,16 @@ export function createDesktopAttachmentStore(): AttachmentStore {
}); });
} }
if (input.source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return await saveDesktopAttachmentFromBase64({
id,
base64: input.source.base64,
mimeType,
fileName,
});
}
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type); const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
const base64 = await blobToBase64(input.source.blob); const base64 = await blobToBase64(input.source.blob);
return await saveDesktopAttachmentFromBase64({ return await saveDesktopAttachmentFromBase64({
@@ -0,0 +1,3 @@
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
error: snapshotError, error: snapshotError,
refresh: refreshSnapshot, refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(formState.serverId, formState.workingDir); } = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo( const snapshotProviderDefinitions = useMemo(
@@ -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);
});
});
});
+152
View File
@@ -0,0 +1,152 @@
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";
import { agentHistoryQueryKey } from "./agent-history-query-key";
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;
};
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,
};
@@ -3,6 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent"; import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent { function makeAgent(overrides: Partial<Agent> = {}): Agent {
@@ -112,6 +113,17 @@ describe("useArchiveAgent", () => {
queryClient.setQueryData(["allAgents", "server-a"], { queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
}); });
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
pages: [
{
agents: [
{ id: "agent-1", archivedAt: null },
{ id: "agent-2", archivedAt: null },
],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({ applyArchivedAgentCloseResults({
queryClient, queryClient,
@@ -131,6 +143,17 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({ expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }], entries: [{ agent: { id: "agent-2" } }],
}); });
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
agents: [
{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") },
{ id: "agent-2", archivedAt: null },
],
},
],
pageParams: [null],
});
}); });
it("can apply archived agent close results without invalidating cached lists", () => { it("can apply archived agent close results without invalidating cached lists", () => {
@@ -143,6 +166,14 @@ describe("useArchiveAgent", () => {
queryClient.setQueryData(["allAgents", "server-a"], { queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
}); });
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
pages: [
{
agents: [{ id: "agent-1", archivedAt: null }],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({ applyArchivedAgentCloseResults({
queryClient, queryClient,
@@ -153,5 +184,14 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false); expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false); expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
agents: [{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") }],
},
],
pageParams: [null],
});
}); });
}); });
+84 -5
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const; export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
@@ -32,6 +33,7 @@ interface AgentsListQueryData {
interface ArchivedAgentListCacheSnapshot { interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined; sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined; allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
} }
interface ArchiveAgentMutationContext { interface ArchiveAgentMutationContext {
@@ -39,6 +41,19 @@ interface ArchiveAgentMutationContext {
lists: ArchivedAgentListCacheSnapshot; lists: ArchivedAgentListCacheSnapshot;
} }
interface AgentHistoryQueryAgent {
id?: string | null;
archivedAt?: Date | null;
}
interface AgentHistoryQueryPage {
agents?: AgentHistoryQueryAgent[];
}
interface AgentHistoryQueryData {
pages?: AgentHistoryQueryPage[];
}
function toArchiveKey(input: ArchiveAgentInput): string { function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim(); const serverId = input.serverId.trim();
const agentId = input.agentId.trim(); const agentId = input.agentId.trim();
@@ -121,6 +136,54 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
); );
} }
function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | undefined>(
payload: T,
input: ArchiveAgentInput & { archivedAt: string },
): T {
if (!payload || !Array.isArray(payload.pages) || !input.agentId) {
return payload;
}
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return payload;
}
let changed = false;
const pages = payload.pages.map((page) => {
if (!Array.isArray(page.agents)) {
return page;
}
let pageChanged = false;
const agents = page.agents.map((agent) => {
if (agent.id !== input.agentId) {
return agent;
}
pageChanged = true;
changed = true;
return {
...agent,
archivedAt,
};
});
return pageChanged ? { ...page, agents } : page;
});
return changed ? ({ ...payload, pages } as T) : payload;
}
function markAgentArchivedInHistoryCache(
queryClient: QueryClient,
input: ArchiveAgentInput & { archivedAt: string },
): void {
queryClient.setQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(input.serverId),
(current) => markAgentArchivedInHistoryPayload(current, input),
);
}
function getStoredAgentSnapshot(input: ArchiveAgentInput) { function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
} }
@@ -161,13 +224,16 @@ function getArchivedAgentListCacheSnapshot(
serverId, serverId,
]), ]),
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]), allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(serverId),
),
}; };
} }
function restoreCachedListSnapshot( function restoreCachedQuerySnapshot<T>(
queryClient: QueryClient, queryClient: QueryClient,
queryKey: readonly [string, string], queryKey: readonly unknown[],
snapshot: AgentsListQueryData | undefined, snapshot: T | undefined,
): void { ): void {
if (snapshot === undefined) { if (snapshot === undefined) {
queryClient.removeQueries({ queryKey, exact: true }); queryClient.removeQueries({ queryKey, exact: true });
@@ -181,12 +247,13 @@ function restoreArchivedAgentListCacheSnapshot(
serverId: string, serverId: string,
snapshot: ArchivedAgentListCacheSnapshot, snapshot: ArchivedAgentListCacheSnapshot,
): void { ): void {
restoreCachedListSnapshot( restoreCachedQuerySnapshot(
queryClient, queryClient,
["sidebarAgentsList", serverId], ["sidebarAgentsList", serverId],
snapshot.sidebarAgentsList, snapshot.sidebarAgentsList,
); );
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents); restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
} }
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void { function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
@@ -235,6 +302,11 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
serverId: input.serverId, serverId: input.serverId,
agentId: result.agentId, agentId: result.agentId,
}); });
markAgentArchivedInHistoryCache(input.queryClient, {
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
});
} }
if (input.invalidateQueries ?? true) { if (input.invalidateQueries ?? true) {
@@ -244,6 +316,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
void input.queryClient.invalidateQueries({ void input.queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId], queryKey: ["allAgents", input.serverId],
}); });
void input.queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
} }
} }
@@ -324,6 +399,9 @@ export function useArchiveAgent() {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId], queryKey: ["allAgents", input.serverId],
}); });
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
}, },
}); });
@@ -359,4 +437,5 @@ export const __private__ = {
setAgentArchiving, setAgentArchiving,
isAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, removeAgentFromListPayload,
markAgentArchivedInHistoryPayload,
}; };
@@ -49,7 +49,10 @@ vi.mock("@server/client/daemon-client", () => ({}));
function createTestClient() { function createTestClient() {
return { return {
isConnected: true, 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>(), sendHeartbeat: vi.fn<(payload: HeartbeatPayload) => void>(),
}; };
} }
@@ -68,13 +71,15 @@ function heartbeatTimeMs(client: ReturnType<typeof createTestClient>): number {
async function renderActivityHook({ async function renderActivityHook({
client = createTestClient(), client = createTestClient(),
focusedAgentId = "agent-1",
}: { }: {
client?: ReturnType<typeof createTestClient>; client?: ReturnType<typeof createTestClient>;
focusedAgentId?: string | null;
} = {}) { } = {}) {
function Probe() { function Probe({ focusedAgentId }: { focusedAgentId: string | null }) {
useClientActivity({ useClientActivity({
client: client as unknown as Parameters<typeof useClientActivity>[0]["client"], client: client as unknown as Parameters<typeof useClientActivity>[0]["client"],
focusedAgentId: "agent-1", focusedAgentId,
}); });
return null; return null;
} }
@@ -84,10 +89,16 @@ async function renderActivityHook({
const root = createRoot(container); const root = createRoot(container);
await act(async () => { 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) { async function advance(ms: number) {
@@ -138,6 +149,24 @@ describe("useClientActivity", () => {
expect(getDesktopSystemIdleTimeMs).not.toHaveBeenCalled(); 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 () => { it("drives lastActivityAt forward from Electron idle polling", async () => {
platformState.isElectron = true; platformState.isElectron = true;
getDesktopSystemIdleTimeMs.mockResolvedValue(0); getDesktopSystemIdleTimeMs.mockResolvedValue(0);
@@ -3,6 +3,7 @@ import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { getIsElectron, isWeb, isNative } from "@/constants/platform"; import { getIsElectron, isWeb, isNative } from "@/constants/platform";
import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle"; import { getDesktopSystemIdleTimeMs } from "@/desktop/electron/idle";
import { useStableEvent } from "@/hooks/use-stable-event";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -41,7 +42,7 @@ export function useClientActivity({
lastActivityAtRef.current = new Date(); lastActivityAtRef.current = new Date();
}, []); }, []);
const sendHeartbeat = useCallback(() => { const sendHeartbeat = useStableEvent(() => {
if (!client.isConnected) return; if (!client.isConnected) return;
client.sendHeartbeat({ client.sendHeartbeat({
deviceType, deviceType,
@@ -50,7 +51,7 @@ export function useClientActivity({
appVisible: appVisibleRef.current, appVisible: appVisibleRef.current,
appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(), appVisibilityChangedAt: appVisibilityChangedAtRef.current.toISOString(),
}); });
}, [client, deviceType, focusedAgentId]); });
const setAppVisible = useCallback( const setAppVisible = useCallback(
(nextVisible: boolean) => { (nextVisible: boolean) => {
@@ -191,10 +192,6 @@ export function useClientActivity({
} }
}); });
if (client.isConnected) {
startHeartbeat();
}
return () => { return () => {
unsubscribe(); unsubscribe();
stopHeartbeat(); stopHeartbeat();
@@ -0,0 +1,157 @@
/**
* @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(onEnterSafeZone).toHaveBeenCalledTimes(1);
expect(onLeaveSafeZone).not.toHaveBeenCalled();
act(() => pointerMove(300, 40));
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => pointerMove(130, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
});
it("refreshes the safe-zone enter callback while moving inside", () => {
const onEnterSafeZone = vi.fn();
const onLeaveSafeZone = vi.fn();
act(() => {
root?.render(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => pointerMove(110, 40));
act(() => pointerMove(130, 40));
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
expect(onLeaveSafeZone).not.toHaveBeenCalled();
});
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);
});
});
+31 -4
View File
@@ -35,6 +35,18 @@ export function useHoverSafeZone({
// The pointer opened the card, so we start inside. // The pointer opened the card, so we start inside.
let wasInside = true; let wasInside = true;
function enterSafeZone() {
if (wasInside) return;
wasInside = true;
onEnterSafeZone();
}
function leaveSafeZone() {
if (!wasInside) return;
wasInside = false;
onLeaveSafeZone();
}
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
const triggerNode = triggerRef.current as unknown as Element | null; const triggerNode = triggerRef.current as unknown as Element | null;
const contentNode = contentRef.current as unknown as Element | null; const contentNode = contentRef.current as unknown as Element | null;
@@ -42,15 +54,30 @@ export function useHoverSafeZone({
const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; const contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
if (inside === wasInside) return; if (inside) {
wasInside = inside; if (wasInside) {
if (inside) onEnterSafeZone(); onEnterSafeZone();
else onLeaveSafeZone(); return;
}
enterSafeZone();
return;
}
leaveSafeZone();
}
function handlePointerOut(event: PointerEvent) {
if (event.relatedTarget === null) {
leaveSafeZone();
}
} }
document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerout", handlePointerOut);
window.addEventListener("blur", leaveSafeZone);
return () => { return () => {
document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerout", handlePointerOut);
window.removeEventListener("blur", leaveSafeZone);
}; };
}, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]);
} }
@@ -11,6 +11,7 @@ const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
routerMock: { routerMock: {
back: vi.fn(), back: vi.fn(),
push: vi.fn(), push: vi.fn(),
replace: vi.fn(),
}, },
pathState: { pathState: {
pathname: "/h/srv/workspace/ws-2", pathname: "/h/srv/workspace/ws-2",
@@ -56,6 +57,7 @@ vi.mock("@/hooks/use-workspace-navigation", () => ({
})); }));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { import {
activateNavigationWorkspaceSelection, activateNavigationWorkspaceSelection,
@@ -80,6 +82,7 @@ describe("useKeyboardShortcuts", () => {
navigateToWorkspaceMock.mockReset(); navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset(); routerMock.back.mockReset();
routerMock.push.mockReset(); routerMock.push.mockReset();
routerMock.replace.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2"; pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null }); syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({ useKeyboardShortcutsStore.setState({
@@ -132,4 +135,57 @@ describe("useKeyboardShortcuts", () => {
}); });
expect(event.defaultPrevented).toBe(true); 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);
});
}); });
@@ -4,6 +4,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildHostWorkspaceRoute,
buildSettingsRoute, buildSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -27,7 +28,10 @@ import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts"; import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id"; 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({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -159,6 +163,11 @@ export function useKeyboardShortcuts({
event: KeyboardEvent; event: KeyboardEvent;
}): boolean => { }): boolean => {
switch (input.action) { switch (input.action) {
case "agent.interrupt":
return keyboardActionDispatcher.dispatch({
id: "agent.interrupt",
scope: "global",
});
case "agent.new": case "agent.new":
return openProjectPicker(); return openProjectPicker();
case "workspace.tab.new": case "workspace.tab.new":
@@ -234,6 +243,18 @@ export function useKeyboardShortcuts({
return true; return true;
case "settings.toggle": case "settings.toggle":
if (pathname.startsWith("/settings")) { if (pathname.startsWith("/settings")) {
if (!isMobile) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(
lastWorkspaceRoute.serverId,
lastWorkspaceRoute.workspaceId,
),
);
return true;
}
}
router.back(); router.back();
return true; return true;
} }
@@ -9,11 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot";
providersSnapshotQueryKey,
shouldApplyProvidersSnapshotUpdate,
useProvidersSnapshot,
} from "./use-providers-snapshot";
type ProviderSnapshotUpdateMessage = { type ProviderSnapshotUpdateMessage = {
type: "providers_snapshot_update"; type: "providers_snapshot_update";
@@ -80,12 +76,12 @@ function enableProvidersSnapshot(): void {
}); });
} }
function renderProvidersSnapshotHook(cwd?: string | null) { function renderProvidersSnapshotHook() {
const queryClient = createQueryClient(); const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children); 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; 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); const listener = snapshotUpdateListeners.at(-1);
expect(listener).toBeDefined(); expect(listener).toBeDefined();
@@ -132,7 +131,7 @@ async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Pr
listener?.({ listener?.({
type: "providers_snapshot_update", type: "providers_snapshot_update",
payload: { payload: {
cwd: "/repo", cwd,
entries, entries,
generatedAt: "2026-01-01T00:00:01.000Z", generatedAt: "2026-01-01T00:00:01.000Z",
}, },
@@ -155,23 +154,8 @@ afterEach(() => {
}); });
describe("providers snapshot hook cache scope", () => { describe("providers snapshot hook cache scope", () => {
it("uses no cwd in the settings query key", () => { it("uses a global query key without cwd", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]); expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
});
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("sends no cwd for settings snapshot loads and refreshes", async () => { 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({}); 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(); enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({ mockClient.refreshProvidersSnapshot.mockResolvedValue({
@@ -204,21 +188,30 @@ describe("providers snapshot hook cache scope", () => {
requestId: "workspace-refresh", requestId: "workspace-refresh",
}); });
const { result } = renderProvidersSnapshotHook("/repo"); const { result } = renderProvidersSnapshotHook();
await waitFor(() => { await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" }); expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
}); });
await act(async () => { await act(async () => {
await result.current.refresh(["codex"]); await result.current.refresh(["codex"]);
}); });
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
cwd: "/repo", expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
providers: ["codex"], });
});
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); 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 () => { 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", [])]))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
renderProvidersSnapshotHook("/repo"); renderProvidersSnapshotHook();
await waitForSnapshotReads(1); await waitForSnapshotReads(1);
await emitProvidersSnapshotUpdate([]); await emitProvidersSnapshotUpdate([]);
@@ -237,7 +230,7 @@ describe("providers snapshot hook cache scope", () => {
await emitProvidersSnapshotUpdate([codexEntry("loading")]); await emitProvidersSnapshotUpdate([codexEntry("loading")]);
await waitForSnapshotReads(2); await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
}); });
@@ -252,13 +245,13 @@ describe("providers snapshot hook cache scope", () => {
.mockResolvedValueOnce(providersSnapshot(entries)) .mockResolvedValueOnce(providersSnapshot(entries))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
const { result } = renderProvidersSnapshotHook("/repo"); const { result } = renderProvidersSnapshotHook();
await waitForSnapshotEntries(result, entries); await waitForSnapshotEntries(result, entries);
await openSelectorForSelectedProvider(result); await openSelectorForSelectedProvider(result);
await waitForSnapshotReads(2); await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
}); });
@@ -266,7 +259,7 @@ describe("providers snapshot hook cache scope", () => {
enableProvidersSnapshot(); enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])])); mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
const { result } = renderProvidersSnapshotHook("/repo"); const { result } = renderProvidersSnapshotHook();
await waitForSnapshotEntries(result, [codexEntry("ready", [])]); await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
await openSelectorForSelectedProvider(result); await openSelectorForSelectedProvider(result);
@@ -6,40 +6,8 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client"; import { queryClient as singletonQueryClient } from "@/query/query-client";
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null { export function providersSnapshotQueryKey(serverId: string | null) {
const trimmed = cwd?.trim(); return ["providersSnapshot", serverId] as const;
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 } : {}),
};
} }
interface UseProvidersSnapshotResult { interface UseProvidersSnapshotResult {
@@ -59,23 +27,17 @@ interface UseProvidersSnapshotOptions {
export function useProvidersSnapshot( export function useProvidersSnapshot(
serverId: string | null, serverId: string | null,
cwd?: string | null,
options: UseProvidersSnapshotOptions = {}, options: UseProvidersSnapshotOptions = {},
): UseProvidersSnapshotResult { ): UseProvidersSnapshotResult {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const enabled = options.enabled ?? true; const enabled = options.enabled ?? true;
const normalizedCwd = cwd?.trim() || undefined;
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
const supportsSnapshot = useSessionStore( const supportsSnapshot = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
); );
const queryKey = useMemo( const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
() => providersSnapshotQueryKey(serverId, normalizedCwdKey),
[normalizedCwdKey, serverId],
);
const snapshotQuery = useQuery({ const snapshotQuery = useQuery({
queryKey, queryKey,
@@ -85,7 +47,7 @@ export function useProvidersSnapshot(
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)); return client.getProvidersSnapshot({});
}, },
}); });
@@ -94,9 +56,7 @@ export function useProvidersSnapshot(
if (!client) { if (!client) {
return; return;
} }
await client.refreshProvidersSnapshot( await client.refreshProvidersSnapshot(providers ? { providers } : {});
refreshProvidersSnapshotRequest(normalizedCwd, providers),
);
}, },
}); });
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
@@ -110,9 +70,6 @@ export function useProvidersSnapshot(
if (message.type !== "providers_snapshot_update") { if (message.type !== "providers_snapshot_update") {
return; return;
} }
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
return;
}
queryClient.setQueryData(queryKey, { queryClient.setQueryData(queryKey, {
entries: message.payload.entries, entries: message.payload.entries,
generatedAt: message.payload.generatedAt, generatedAt: message.payload.generatedAt,
@@ -127,17 +84,7 @@ export function useProvidersSnapshot(
}); });
} }
}); });
}, [ }, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
client,
enabled,
isConnected,
normalizedCwd,
normalizedCwdKey,
queryClient,
queryKey,
serverId,
supportsSnapshot,
]);
const refresh = useCallback( const refresh = useCallback(
async (providers?: AgentProvider[]) => { async (providers?: AgentProvider[]) => {
@@ -145,10 +92,10 @@ export function useProvidersSnapshot(
return; return;
} }
await refreshSnapshot(providers); await refreshSnapshot(providers);
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)); const snapshot = await client.getProvidersSnapshot({});
queryClient.setQueryData(queryKey, snapshot); queryClient.setQueryData(queryKey, snapshot);
}, },
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot], [client, queryClient, queryKey, refreshSnapshot],
); );
const refetchIfStale = useCallback( const refetchIfStale = useCallback(
@@ -184,16 +131,11 @@ export function useProvidersSnapshot(
}; };
} }
export function prefetchProvidersSnapshot( export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
serverId: string, const queryKey = providersSnapshotQueryKey(serverId);
client: DaemonClient,
cwd?: string | null,
): void {
const normalizedCwd = cwd?.trim() || undefined;
const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd);
void singletonQueryClient.prefetchQuery({ void singletonQueryClient.prefetchQuery({
queryKey, queryKey,
staleTime: 60_000, staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)), queryFn: () => client.getProvidersSnapshot({}),
}); });
} }
+1
View File
@@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind =
| "voice-mute-toggle"; | "voice-mute-toggle";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt"
| "agent.new" | "agent.new"
| "workspace.tab.new" | "workspace.tab.new"
| "workspace.tab.close.current" | "workspace.tab.close.current"
@@ -1,6 +1,7 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt"
| "message-input.focus" | "message-input.focus"
| "message-input.send" | "message-input.send"
| "message-input.dictation-toggle" | "message-input.dictation-toggle"
@@ -29,6 +30,7 @@ export type KeyboardActionId =
| "worktree.archive"; | "worktree.archive";
export type KeyboardActionDefinition = export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
| { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.send"; scope: KeyboardActionScope } | { id: "message-input.send"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
@@ -280,11 +280,10 @@ describe("keyboard-shortcuts", () => {
payload: { kind: "voice-mute-toggle" }, 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" }, event: { key: "Escape", code: "Escape" },
context: { focusScope: "message-input" }, context: { focusScope: "message-input" },
action: "message-input.action", action: "agent.interrupt",
payload: { kind: "dictation-cancel" },
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
}, },
@@ -353,6 +352,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true }, event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" }, 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", name: "does not bind pane shortcuts on non-mac platforms",
event: { key: "\\", code: "Backslash", ctrlKey: true }, event: { key: "\\", code: "Backslash", ctrlKey: true },
@@ -869,17 +869,16 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
}, },
{ {
id: "message-input-dictation-cancel", id: "agent-interrupt",
action: "message-input.action", action: "agent.interrupt",
combo: "Escape", combo: "Escape",
when: { commandCenter: false, terminal: false }, when: { commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "dictation-cancel" },
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
help: { help: {
id: "dictation-cancel", id: "agent-interrupt",
section: "agent-input", section: "agent-input",
label: "Cancel dictation", label: "Interrupt agent",
keys: ["Esc"], keys: ["Esc"],
}, },
}, },
+97 -2
View File
@@ -43,6 +43,7 @@ const {
streamRenderCount, streamRenderCount,
latestStreamPermissionKeys, latestStreamPermissionKeys,
latestStreamText, latestStreamText,
runtimeIsConnected,
theme, theme,
runtimeClient, runtimeClient,
} = vi.hoisted(() => { } = vi.hoisted(() => {
@@ -71,6 +72,7 @@ const {
streamRenderCount: vi.fn(), streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] }, latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null }, latestStreamText: { current: null as string | null },
runtimeIsConnected: { current: false },
theme: { theme: {
colors: { colors: {
foreground: "#ffffff", foreground: "#ffffff",
@@ -121,8 +123,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles)
vi.mock("@/runtime/host-runtime", () => ({ vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }], useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient, useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => false, useHostRuntimeIsConnected: () => runtimeIsConnected.current,
useHostRuntimeConnectionStatus: () => "offline", useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
useHostRuntimeLastError: () => null, 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()) { function seedReadyAgent(agent: Agent = makeAgent()) {
const store = useSessionStore.getState(); const store = useSessionStore.getState();
store.initializeSession("server", makeClient()); store.initializeSession("server", makeClient());
@@ -367,6 +414,9 @@ describe("AgentPanel render isolation", () => {
streamRenderCount.mockClear(); streamRenderCount.mockClear();
latestStreamPermissionKeys.current = []; latestStreamPermissionKeys.current = [];
latestStreamText.current = null; 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 () => { 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(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2); 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);
});
}); });
+105 -88
View File
@@ -1,53 +1,54 @@
import type { DaemonClient } from "@server/client/daemon-client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { Composer } from "@/components/composer";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import { getProviderIcon } from "@/components/provider-icons";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host"; import { ToastViewport, useToastHost } from "@/components/toast-host";
import { isNative } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { import {
useAgentScreenStateMachine,
type AgentScreenAgent, type AgentScreenAgent,
type AgentScreenMissingState, type AgentScreenMissingState,
useAgentScreenStateMachine,
} from "@/hooks/use-agent-screen-state-machine"; } from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { import {
type HostRuntimeConnectionStatus,
useHostRuntimeClient, useHostRuntimeClient,
useHostRuntimeConnectionStatus, useHostRuntimeConnectionStatus,
useHostRuntimeIsConnected, useHostRuntimeIsConnected,
useHostRuntimeLastError, useHostRuntimeLastError,
useHosts, useHosts,
type HostRuntimeConnectionStatus,
} from "@/runtime/host-runtime"; } 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 { import {
deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } 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 { function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) { if (!provider) {
@@ -74,13 +75,63 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
return normalized; 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( function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string }, target: { kind: "agent"; agentId: string },
context: { serverId: string }, context: { serverId: string },
): PanelDescriptor { ): PanelDescriptor {
const descriptorState = useSessionStore( const descriptorState = useSessionStore(
useShallow((state) => { 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 { return {
provider: agent?.provider ?? "codex", provider: agent?.provider ?? "codex",
title: agent?.title ?? null, title: agent?.title ?? null,
@@ -262,17 +313,27 @@ function AgentPanelBody({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const setAgents = useSessionStore((state) => state.setAgents);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const projectPlacement = useStoreWithEqualityFn( const projectPlacement = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => (state) => {
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, 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), (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { 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 { return {
serverId: agent?.serverId ?? null, serverId: agent?.serverId ?? null,
id: agent?.id ?? null, id: agent?.id ?? null,
@@ -325,29 +386,7 @@ function AgentPanelBody({
return; return;
} }
const normalized = normalizeAgentSnapshot(result.agent, serverId); storeFetchedAgentDetail({ serverId, result });
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;
});
setLookupState({ tag: "idle" }); setLookupState({ tag: "idle" });
}) })
.catch((error) => { .catch((error) => {
@@ -361,17 +400,7 @@ function AgentPanelBody({
} }
setLookupState({ tag: "error", message }); setLookupState({ tag: "error", message });
}); });
}, [ }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
serverId,
setAgents,
setPendingPermissions,
]);
if (lookupState.tag === "not_found") { if (lookupState.tag === "not_found") {
return ( return (
@@ -471,7 +500,10 @@ function ChatAgentContent({
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { 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 { return {
serverId: agent?.serverId ?? null, serverId: agent?.serverId ?? null,
id: agent?.id ?? null, id: agent?.id ?? null,
@@ -486,8 +518,17 @@ function ChatAgentContent({
); );
const projectPlacement = useStoreWithEqualityFn( const projectPlacement = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => (state) => {
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, 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), (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
@@ -505,8 +546,6 @@ function ChatAgentContent({
const agentHistorySyncGeneration = useSessionStore((state) => const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, 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 hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({ const { ensureAgentIsInitialized } = useAgentInitialization({
serverId, serverId,
@@ -776,7 +815,9 @@ function ChatAgentContent({
if (attemptToken !== initAttemptTokenRef.current) { if (attemptToken !== initAttemptTokenRef.current) {
return; 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) { if (!currentAgent) {
const result = await client.fetchAgent(agentId); const result = await client.fetchAgent(agentId);
if (attemptToken !== initAttemptTokenRef.current) { if (attemptToken !== initAttemptTokenRef.current) {
@@ -789,29 +830,7 @@ function ChatAgentContent({
}); });
return; return;
} }
const normalized = normalizeAgentSnapshot(result.agent, serverId); storeFetchedAgentDetail({ serverId, result });
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;
});
} }
if (attemptToken !== initAttemptTokenRef.current) { if (attemptToken !== initAttemptTokenRef.current) {
return; return;
@@ -838,8 +857,6 @@ function ChatAgentContent({
isConnected, isConnected,
missingAgentState.kind, missingAgentState.kind,
serverId, serverId,
setAgents,
setPendingPermissions,
shouldUseOptimisticStream, shouldUseOptimisticStream,
]); ]);
+126 -53
View File
@@ -213,17 +213,21 @@ function makeDeps(
return client as unknown as DaemonClient; return client as unknown as DaemonClient;
}, },
connectToDaemon: async ({ host, connection }) => { connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id]; const readLatency = (): number => {
if (value instanceof Error) { const value = latencyByConnectionId[connection.id];
throw value; if (value instanceof Error) {
} throw value;
if (typeof value !== "number") { }
throw new Error(`missing latency for ${connection.id}`); if (typeof value !== "number") {
} throw new Error(`missing latency for ${connection.id}`);
}
return value;
};
readLatency();
const client = new FakeDaemonClient(); const client = new FakeDaemonClient();
client.connectCalls = 1; client.connectCalls = 1;
client.setConnectionState({ status: "connected" }); client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: value }); client.ping = async () => ({ rttMs: readLatency() });
createdClients.push(client); createdClients.push(client);
return { return {
client: client as unknown as DaemonClient, client: client as unknown as DaemonClient,
@@ -433,7 +437,95 @@ describe("HostRuntimeController", () => {
await probeCycle; 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 host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = []; const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = { const latencies: Record<string, number | Error> = {
@@ -450,6 +542,9 @@ describe("HostRuntimeController", () => {
const initialClient = controller.getSnapshot().client; const initialClient = controller.getSnapshot().client;
expect(initialClient).toBeTruthy(); 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["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42; latencies["relay:relay.paseo.sh:443"] = 42;
clearProbeBackoff(controller); clearProbeBackoff(controller);
@@ -838,7 +933,7 @@ describe("HostRuntimeController", () => {
expect(createdClients[0]?.closeCalls).toBe(1); 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({ const host = makeHost({
connections: [ connections: [
{ {
@@ -849,7 +944,6 @@ describe("HostRuntimeController", () => {
], ],
}); });
const slowProbe = createDeferred<number>(); const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0; let probeCalls = 0;
const controller = new HostRuntimeController({ const controller = new HostRuntimeController({
@@ -865,9 +959,6 @@ describe("HostRuntimeController", () => {
if (probeCalls === 1) { if (probeCalls === 1) {
return { rttMs: await slowProbe.promise }; return { rttMs: await slowProbe.promise };
} }
if (probeCalls === 2) {
return { rttMs: await fastProbe.promise };
}
throw new Error("unexpected probe call"); throw new Error("unexpected probe call");
}; };
return { return {
@@ -883,23 +974,14 @@ describe("HostRuntimeController", () => {
const first = controller.runProbeCycleNow(); const first = controller.runProbeCycleNow();
clearProbeBackoff(controller); clearProbeBackoff(controller);
const second = controller.runProbeCycleNow(); const second = controller.runProbeCycleNow();
expect(probeCalls).toBe(1);
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
slowProbe.resolve(900); slowProbe.resolve(900);
await first; await Promise.all([first, second]);
const probeAfterFirstSettles = controller const probeAfterCycle = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
.getSnapshot() expect(probeAfterCycle).toEqual({
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
status: "available", status: "available",
latencyMs: 12, latencyMs: 900,
}); });
}); });
@@ -1112,7 +1194,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_test" }, subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 }, page: { limit: 200 },
@@ -1160,7 +1242,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_no_session" }, subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 }, page: { limit: 200 },
@@ -1169,7 +1251,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]); 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({ const host = makeHost({
serverId: "srv_paged", serverId: "srv_paged",
connections: [ connections: [
@@ -1234,13 +1316,13 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(2); expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_paged" }, subscribe: { subscriptionId: "app:srv_paged" },
page: { limit: 200 }, page: { limit: 200 },
}); });
expect(fakeClient.fetchAgentsCalls[1]).toEqual({ expect(fakeClient.fetchAgentsCalls[1]).toEqual({
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-page-2" }, page: { limit: 200, cursor: "cursor-page-2" },
}); });
@@ -1314,13 +1396,13 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toEqual([ expect(fakeClient.fetchAgentsCalls).toEqual([
{ {
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" }, subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 }, page: { limit: 200 },
}, },
{ {
filter: { includeArchived: true }, scope: "active",
sort: [{ key: "updated_at", direction: "desc" }], sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" }, subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 }, page: { limit: 200 },
@@ -1331,7 +1413,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId); 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({ const host = makeHost({
serverId: "srv_archived_rehydrate", serverId: "srv_archived_rehydrate",
connections: [ connections: [
@@ -1346,15 +1428,7 @@ describe("HostRuntimeStore", () => {
fakeClient.setConnectionState({ status: "connected" }); fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push( fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({ makeFetchAgentsPayload({
entries: [ 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",
}),
],
subscriptionId: "app:srv_archived_rehydrate", subscriptionId: "app:srv_archived_rehydrate",
}), }),
); );
@@ -1397,17 +1471,16 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]); store.syncHosts([host]);
const timeoutAt = Date.now() + 300; const timeoutAt = Date.now() + 300;
let archivedAt = while (
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived") useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
?.archivedAt ?? null; Date.now() < timeoutAt
while (!archivedAt && Date.now() < timeoutAt) { ) {
await new Promise((resolve) => setTimeout(resolve, 0)); 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([]); store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId); useSessionStore.getState().clearSession(host.serverId);
+73 -47
View File
@@ -4,13 +4,14 @@ import equal from "fast-deep-equal/es6";
import { import {
DaemonClient, DaemonClient,
type ConnectionState, type ConnectionState,
type FetchAgentsEntry,
type FetchAgentsOptions, type FetchAgentsOptions,
} from "@server/client/daemon-client"; } from "@server/client/daemon-client";
import { import {
connectionFromListen, connectionFromListen,
normalizeStoredHostProfile, normalizeStoredHostProfile,
upsertHostConnectionInProfiles, upsertHostConnectionInProfiles,
registryHasDirectEndpoint, registryHasConnection,
type HostConnection, type HostConnection,
type HostProfile, type HostProfile,
} from "@/types/host-connection"; } from "@/types/host-connection";
@@ -31,8 +32,8 @@ import {
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
import { isDev } from "@/constants/platform"; import { isDev } from "@/constants/platform";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
@@ -488,6 +489,7 @@ export class HostRuntimeController {
private clientIdHash: string | null = null; private clientIdHash: string | null = null;
private switchRequestVersion = 0; private switchRequestVersion = 0;
private probeRequestVersion = 0; private probeRequestVersion = 0;
private probeCycleInFlight: Promise<void> | null = null;
constructor(input: { constructor(input: {
host: HostProfile; host: HostProfile;
@@ -628,6 +630,20 @@ export class HostRuntimeController {
} }
async runProbeCycleNow(): Promise<void> { 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; const requestVersion = ++this.probeRequestVersion;
if (this.host.connections.length === 0) { if (this.host.connections.length === 0) {
if (!this.isCurrentProbeRequest(requestVersion)) { if (!this.isCurrentProbeRequest(requestVersion)) {
@@ -663,10 +679,15 @@ export class HostRuntimeController {
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId); const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
for (const connection of connectionsToProbe) { for (const connection of connectionsToProbe) {
this.connectionLastProbedAt.set(connection.id, performance.now()); this.connectionLastProbedAt.set(connection.id, performance.now());
probeByConnectionId.set(connection.id, { const existingProbe = probeByConnectionId.get(connection.id);
status: "pending", const shouldPreserveActiveLatency =
latencyMs: null, isOnline && connection.id === activeConnectionId && existingProbe?.status === "available";
}); if (!shouldPreserveActiveLatency) {
probeByConnectionId.set(connection.id, {
status: "pending",
latencyMs: null,
});
}
} }
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
@@ -800,22 +821,39 @@ export class HostRuntimeController {
for (const connection of connectionsToProbe) { for (const connection of connectionsToProbe) {
void (async () => { void (async () => {
let connectedClient: DaemonClient | null = null; let connectedClient: DaemonClient | null = null;
let handedOffClient = false; let shouldCloseClient = false;
try { try {
const { client } = await this.deps.connectToDaemon({ const activeClient =
host: this.host, this.snapshot.connectionStatus === "online" &&
connection, this.snapshot.activeConnectionId === connection.id
}); ? this.snapshot.client
connectedClient = 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)) { if (!this.isCurrentProbeRequest(requestVersion)) {
return; return;
} }
const activated = await maybeActivateFirstAvailable(connection.id, client); const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
handedOffClient = activated; shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await client.ping({ timeoutMs: 5000 }); const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) { if (!this.isCurrentProbeRequest(requestVersion)) {
return; return;
} }
@@ -834,7 +872,7 @@ export class HostRuntimeController {
publishProbeState(); publishProbeState();
} }
} finally { } finally {
if (connectedClient && !handedOffClient) { if (connectedClient && shouldCloseClient) {
await connectedClient.close().catch(() => undefined); await connectedClient.close().catch(() => undefined);
} }
settleProbe(); settleProbe();
@@ -1099,7 +1137,6 @@ export class HostRuntimeController {
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767"; 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 DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000; const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e"; const E2E_STORAGE_KEY = "@paseo:e2e";
@@ -1229,33 +1266,22 @@ export class HostRuntimeStore {
private async bootstrapLocalhost(): Promise<void> { private async bootstrapLocalhost(): Promise<void> {
try { try {
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY); const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
if (alreadyHandled) { if (!connection || registryHasConnection(this.hosts, connection)) {
return;
}
if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
return; return;
} }
try { try {
const { client, serverId, hostname } = await connectToDaemon( const { client, serverId, hostname } = await connectToDaemon(connection, {
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, });
type: "directTcp",
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS },
);
await this.upsertDirectConnection({ await this.upsertHostConnection({
serverId, serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined, label: hostname ?? undefined,
connection,
existingClient: client, existingClient: client,
}); });
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
} catch { } catch {
// Best-effort bootstrap only // Best-effort bootstrap only
} }
@@ -1759,7 +1785,7 @@ export class HostRuntimeStore {
subscribe?: FetchAgentsOptions["subscribe"]; subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"]; page?: FetchAgentsOptions["page"];
}): Promise<{ }): Promise<{
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"]; agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
subscriptionId: string | null; subscriptionId: string | null;
}> { }> {
const controller = this.controllers.get(input.serverId); const controller = this.controllers.get(input.serverId);
@@ -1778,23 +1804,18 @@ export class HostRuntimeStore {
let cursor = input.page?.cursor ?? null; let cursor = input.page?.cursor ?? null;
let includeSubscribe = true; let includeSubscribe = true;
let subscriptionId: string | null = null; let subscriptionId: string | null = null;
const allAgents = new Map<string, Agent>(); const allEntries: FetchAgentsEntry[] = [];
while (true) { while (true) {
const payload = await client.fetchAgents({ 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, sort: DEFAULT_AGENT_DIRECTORY_SORT,
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
}); });
const pageAgents = applyFetchedAgentDirectory({ allEntries.push(...payload.entries);
serverId: input.serverId,
entries: payload.entries,
}).agents;
for (const [agentId, agent] of pageAgents) {
allAgents.set(agentId, agent);
}
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false; includeSubscribe = false;
@@ -1810,9 +1831,14 @@ export class HostRuntimeStore {
cursor = nextCursor; cursor = nextCursor;
} }
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: allEntries,
});
controller.markAgentDirectorySyncReady(); controller.markAgentDirectorySyncReady();
return { return {
agents: allAgents, agents,
subscriptionId, subscriptionId,
}; };
} catch (error) { } catch (error) {
@@ -465,6 +465,7 @@ function DraftAgentScreenContent({
}, },
enabled: enabled:
isCreateWorktree && isCreateWorktree &&
isBranchOpen &&
isGitDirectory && isGitDirectory &&
!isNonGitDirectory && !isNonGitDirectory &&
Boolean(trimmedWorkingDir) && Boolean(trimmedWorkingDir) &&
@@ -13,8 +13,10 @@ const {
theme, theme,
mockClient, mockClient,
mergeWorkspacesMock, mergeWorkspacesMock,
setAgentsMock,
navigateMock, navigateMock,
saveDraftInputMock,
clearDraftInputMock,
queueDraftSubmissionMock,
createdAgent, createdAgent,
createdWorkspace, createdWorkspace,
prItem, prItem,
@@ -113,8 +115,10 @@ const {
theme, theme,
mockClient, mockClient,
mergeWorkspacesMock: vi.fn(), mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(), navigateMock: vi.fn(),
saveDraftInputMock: vi.fn(),
clearDraftInputMock: vi.fn(),
queueDraftSubmissionMock: vi.fn(),
createdAgent, createdAgent,
createdWorkspace, createdWorkspace,
prItem, 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", () => ({ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient, useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true, useHostRuntimeIsConnected: () => true,
@@ -168,7 +187,6 @@ vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) => useSessionStore: (selector: (state: unknown) => unknown) =>
selector({ selector({
mergeWorkspaces: mergeWorkspacesMock, mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}), }),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace, normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
})); }));
@@ -195,6 +213,36 @@ vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateMock, 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", () => ({ vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }), 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", () => ({ vi.mock("@/components/composer", () => ({
Composer: ({ Composer: ({
onSubmitMessage, onSubmitMessage,
@@ -444,6 +496,10 @@ beforeEach(() => {
}); });
mockClient.createPaseoWorktree.mockClear(); mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear(); mockClient.createAgent.mockClear();
saveDraftInputMock.mockClear();
clearDraftInputMock.mockClear();
queueDraftSubmissionMock.mockClear();
navigateMock.mockClear();
initialAttachments.length = 0; initialAttachments.length = 0;
initialDraftState.text = ""; initialDraftState.text = "";
}); });
@@ -522,10 +578,23 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
} }
describe("NewWorkspaceScreen picker payload", () => { describe("NewWorkspaceScreen picker payload", () => {
it("searches only GitHub PRs for the picker", async () => { it("moves the ref picker row with the mobile keyboard shift", async () => {
renderScreen(); renderScreen();
await flush(); await flush();
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
expect(pickerRow.style.transform).toBe("translateY(-216px)");
});
it("lazily searches only GitHub PRs when the picker opens", async () => {
renderScreen();
await flush();
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(mockClient.searchGitHub).toHaveBeenCalledWith({ expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo", cwd: "/repo",
query: "", query: "",
@@ -552,6 +621,50 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(call).not.toHaveProperty("githubPrNumber"); 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 () => { it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen(); renderScreen();
await flush(); await flush();
@@ -747,10 +860,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"; initialDraftState.text = "please review this change";
const createAgent = createDeferredPromise<typeof createdAgent>(); const createWorktree = createDeferredPromise<{
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise); workspace: typeof createdWorkspace;
error: null;
}>();
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
renderScreen(); renderScreen();
await flush(); await flush();
@@ -777,8 +893,9 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true); expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true); expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).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(); await flush();
expect(textInput).toHaveProperty("value", "please review this change"); expect(textInput).toHaveProperty("value", "please review this change");
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native"; 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 { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { encodeImages } from "@/utils/encode-images"; import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input"; import type { ImageAttachment, MessagePayload } from "@/components/message-input";
@@ -105,9 +107,11 @@ export function NewWorkspaceScreen({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const toast = useToast(); const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType< const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor typeof normalizeWorkspaceDescriptor
@@ -131,8 +135,9 @@ export function NewWorkspaceScreen({
const isPending = pendingAction !== null; const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const draftKey = `new-workspace:${serverId}:${sourceDirectory}`;
const chatDraft = useAgentInputDraft({ const chatDraft = useAgentInputDraft({
draftKey: `new-workspace:${serverId}:${sourceDirectory}`, draftKey,
initialCwd: sourceDirectory, initialCwd: sourceDirectory,
composer: { composer: {
initialServerId: serverId || null, initialServerId: serverId || null,
@@ -178,7 +183,7 @@ export function NewWorkspaceScreen({
limit: 20, limit: 20,
}); });
}, },
enabled: isConnected && !!client, enabled: pickerOpen && isConnected && !!client,
staleTime: 15_000, staleTime: 15_000,
}); });
@@ -193,7 +198,7 @@ export function NewWorkspaceScreen({
kinds: ["github-pr"], kinds: ["github-pr"],
}); });
}, },
enabled: isConnected && !!client, enabled: pickerOpen && isConnected && !!client,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -319,10 +324,6 @@ export function NewWorkspaceScreen({
try { try {
setPendingAction("chat"); setPendingAction("chat");
setErrorMessage(null); setErrorMessage(null);
const { images, attachments: reviewAttachments } =
splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const connectedClient = withConnectedClient();
if (!composerState) { if (!composerState) {
throw new Error("Composer state is required"); throw new Error("Composer state is required");
} }
@@ -330,15 +331,30 @@ export function NewWorkspaceScreen({
throw new Error("Select a model"); throw new Error("Select a model");
} }
const initialPrompt = text.trim(); const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(images); const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const workspaceDirectory = requireWorkspaceExecutionAuthority({ const draftId = generateDraftId();
workspace, const workspaceDirectory = workspace.workspaceDirectory;
}).workspaceDirectory; useDraftStore.getState().saveDraftInput({
const agent = await connectedClient.createAgent({ draftKey: buildDraftStoreKey({
provider: composerState.selectedProvider, serverId,
cwd: workspaceDirectory, agentId: draftId,
draftId,
}),
draft: {
text,
attachments,
cwd: workspaceDirectory,
},
});
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId: workspace.id, workspaceId: workspace.id,
draftId,
text,
attachments,
cwd: workspaceDirectory,
provider: composerState.selectedProvider,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode } ? { modeId: composerState.selectedMode }
: {}), : {}),
@@ -346,31 +362,24 @@ export function NewWorkspaceScreen({
...(composerState.effectiveThinkingOptionId ...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId } ? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}), : {}),
...(initialPrompt ? { initialPrompt } : {}), ...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), allowEmptyText: true,
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
});
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
}); });
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId, serverId,
workspaceId: workspace.id, workspaceId: workspace.id,
target: { kind: "agent", agentId: agent.id }, target: { kind: "draft", draftId },
navigationMethod: "replace", navigationMethod: "replace",
}); });
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} catch (error) { } catch (error) {
const message = toErrorMessage(error); const message = toErrorMessage(error);
setPendingAction(null);
setErrorMessage(message); setErrorMessage(message);
toast.error(message); toast.error(message);
} finally {
setPendingAction(null);
} }
}, },
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient], [composerState, draftKey, ensureWorkspace, serverId, toast],
); );
const workspaceTitle = const workspaceTitle =
@@ -494,7 +503,10 @@ export function NewWorkspaceScreen({
} }
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
/> />
<View style={styles.optionsRow}> <Animated.View
testID="new-workspace-ref-picker-row"
style={[styles.optionsRow, keyboardAnimatedStyle]}
>
<View> <View>
<Tooltip> <Tooltip>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
@@ -557,7 +569,7 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption} renderOption={renderPickerOption}
/> />
</View> </View>
</View> </Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View> </View>
</View> </View>

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