Compare commits

..

1 Commits

Author SHA1 Message Date
Mohamed Boudra
00870740ef Fix committed diff excluding dirty worktree 2026-04-21 15:00:08 +07:00
202 changed files with 1974 additions and 13167 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-5exQLdsyyKhlQzrcpBKr3xidAZ3RvQ6wvzf2XyaSPJ4=";
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

144
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
@@ -28,108 +28,6 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -293,7 +191,7 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
@@ -364,87 +262,12 @@ test.describe("New workspace flow", () => {
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await expect(composer).toBeEditable({ timeout: 30_000 });
} finally {
await tempRepo.cleanup();
}
});
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
{
timeout: 30_000,
},
);
const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0);
agentCreatedDelay.release();
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(1, { timeout: 30_000 });
} finally {
agentCreatedDelay.release();
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
routerMock: {
back: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
},
pathState: {
pathname: "/h/srv/workspace/ws-2",
@@ -57,7 +56,6 @@ vi.mock("@/hooks/use-workspace-navigation", () => ({
}));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
activateNavigationWorkspaceSelection,
@@ -82,7 +80,6 @@ describe("useKeyboardShortcuts", () => {
navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset();
routerMock.push.mockReset();
routerMock.replace.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({
@@ -135,57 +132,4 @@ describe("useKeyboardShortcuts", () => {
});
expect(event.defaultPrevented).toBe(true);
});
it("dispatches Escape as an agent interrupt action", async () => {
const handleInterrupt = vi.fn(() => true);
const unregister = keyboardActionDispatcher.registerHandler({
handlerId: "test-agent-interrupt",
actions: ["agent.interrupt"],
enabled: true,
priority: 100,
isActive: () => true,
handle: handleInterrupt,
});
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(handleInterrupt).toHaveBeenCalledTimes(1);
unregister();
});
it("returns from desktop settings to the retained workspace without browser history back", async () => {
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
},
});
pathState.pathname = "/settings/general";
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: ",",
code: "Comma",
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
expect(routerMock.back).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,6 @@ const {
streamRenderCount,
latestStreamPermissionKeys,
latestStreamText,
runtimeIsConnected,
theme,
runtimeClient,
} = vi.hoisted(() => {
@@ -72,7 +71,6 @@ const {
streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null },
runtimeIsConnected: { current: false },
theme: {
colors: {
foreground: "#ffffff",
@@ -123,8 +121,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles)
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
useHostRuntimeIsConnected: () => false,
useHostRuntimeConnectionStatus: () => "offline",
useHostRuntimeLastError: () => null,
}));
@@ -242,51 +240,6 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
};
}
function makeFetchedAgentResult(agent: Agent): Awaited<ReturnType<DaemonClient["fetchAgent"]>> {
return {
project: {
projectKey: agent.cwd,
projectName: "workspace",
checkout: {
cwd: agent.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
agent: {
id: agent.id,
provider: agent.provider,
status: agent.status,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
runtimeInfo: agent.runtimeInfo ?? {
provider: agent.provider,
sessionId: null,
},
capabilities: agent.capabilities,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
pendingPermissions: agent.pendingPermissions,
persistence: agent.persistence,
title: agent.title,
cwd: agent.cwd,
model: agent.model,
thinkingOptionId: agent.thinkingOptionId ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
archivedAt: agent.archivedAt?.toISOString() ?? null,
labels: agent.labels,
lastError: agent.lastError ?? undefined,
},
};
}
function seedReadyAgent(agent: Agent = makeAgent()) {
const store = useSessionStore.getState();
store.initializeSession("server", makeClient());
@@ -414,9 +367,6 @@ describe("AgentPanel render isolation", () => {
streamRenderCount.mockClear();
latestStreamPermissionKeys.current = [];
latestStreamText.current = null;
runtimeIsConnected.current = false;
runtimeClient.fetchAgent.mockReset();
runtimeClient.fetchAgentTimeline.mockReset();
});
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
@@ -513,49 +463,4 @@ describe("AgentPanel render isolation", () => {
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
});
it("renders an archived lazy detail without adding it to the active agent store", async () => {
const archivedAgent = makeAgent({
archivedAt: new Date("2026-04-20T00:00:02.000Z"),
});
runtimeIsConnected.current = true;
runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent));
runtimeClient.fetchAgentTimeline.mockResolvedValue({
agent: null,
events: [],
nextCursor: null,
hasMore: false,
});
useSessionStore
.getState()
.initializeSession("server", runtimeClient as unknown as DaemonClient);
useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root, {
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
});
const timeoutAt = Date.now() + 300;
while (
!container?.querySelector('[data-testid="archived-agent-callout"]') &&
Date.now() < timeoutAt
) {
await act(async () => {
await Promise.resolve();
});
}
expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull();
expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false);
expect(
useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt,
).toEqual(archivedAgent.archivedAt);
});
});

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,8 @@ const {
theme,
mockClient,
mergeWorkspacesMock,
setAgentsMock,
navigateMock,
saveDraftInputMock,
clearDraftInputMock,
queueDraftSubmissionMock,
createdAgent,
createdWorkspace,
prItem,
@@ -115,10 +113,8 @@ const {
theme,
mockClient,
mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(),
saveDraftInputMock: vi.fn(),
clearDraftInputMock: vi.fn(),
queueDraftSubmissionMock: vi.fn(),
createdAgent,
createdWorkspace,
prItem,
@@ -163,21 +159,6 @@ vi.mock("lucide-react-native", () => {
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({
testID,
style,
...props
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
const flattenedStyle = Array.isArray(style)
? Object.assign({}, ...style.filter(Boolean))
: style;
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
},
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
@@ -187,6 +168,7 @@ vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
}));
@@ -213,36 +195,6 @@ vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateMock,
}));
vi.mock("@/stores/draft-keys", () => ({
buildDraftStoreKey: ({
serverId,
agentId,
draftId,
}: {
serverId: string;
agentId: string;
draftId?: string | null;
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
generateDraftId: () => "draft-new-workspace",
}));
vi.mock("@/stores/draft-store", () => ({
useDraftStore: {
getState: () => ({
saveDraftInput: saveDraftInputMock,
clearDraftInput: clearDraftInputMock,
}),
},
}));
vi.mock("@/stores/workspace-draft-submission-store", () => ({
useWorkspaceDraftSubmissionStore: {
getState: () => ({
setPending: queueDraftSubmissionMock,
}),
},
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
@@ -270,10 +222,6 @@ vi.mock("@/hooks/use-agent-input-draft", () => ({
},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
}));
vi.mock("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
@@ -496,10 +444,6 @@ beforeEach(() => {
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
saveDraftInputMock.mockClear();
clearDraftInputMock.mockClear();
queueDraftSubmissionMock.mockClear();
navigateMock.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
@@ -578,14 +522,6 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
}
describe("NewWorkspaceScreen picker payload", () => {
it("moves the ref picker row with the mobile keyboard shift", async () => {
renderScreen();
await flush();
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
expect(pickerRow.style.transform).toBe("translateY(-216px)");
});
it("searches only GitHub PRs for the picker", async () => {
renderScreen();
await flush();
@@ -616,50 +552,6 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(call).not.toHaveProperty("githubPrNumber");
});
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
initialDraftState.text = "please review this change";
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
expect(mockClient.createAgent).not.toHaveBeenCalled();
expect(saveDraftInputMock).toHaveBeenCalledWith({
draftKey: "draft:server:draft-new-workspace",
draft: {
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
},
});
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
draftId: "draft-new-workspace",
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
provider: "claude-code",
allowEmptyText: true,
});
expect(navigateMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
target: { kind: "draft", draftId: "draft-new-workspace" },
navigationMethod: "replace",
});
expect(clearDraftInputMock).toHaveBeenCalledWith({
draftKey: "new-workspace:server:/repo",
lifecycle: "sent",
});
expect(navigateMock.mock.invocationCallOrder[0]).toBeLessThan(
clearDraftInputMock.mock.invocationCallOrder[0],
);
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
});
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen();
await flush();
@@ -855,13 +747,10 @@ describe("NewWorkspaceScreen picker payload", () => {
);
});
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
initialDraftState.text = "please review this change";
const createWorktree = createDeferredPromise<{
workspace: typeof createdWorkspace;
error: null;
}>();
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
const createAgent = createDeferredPromise<typeof createdAgent>();
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
renderScreen();
await flush();
@@ -888,9 +777,8 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
expect(clearDraftInputMock).not.toHaveBeenCalled();
createWorktree.reject(new Error("Create worktree failed"));
createAgent.reject(new Error("Create agent failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");

View File

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

View File

@@ -1,158 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentHistoryResult } from "@/hooks/use-agent-history";
import { SessionsScreen } from "@/screens/sessions-screen";
const { historyResult, navigate } = vi.hoisted(() => ({
historyResult: {
current: null as AgentHistoryResult | null,
},
navigate: vi.fn(),
}));
vi.mock("react-native", () => ({
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("div", props, children),
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("span", props, children),
}));
vi.mock("react-native-unistyles", () => {
const theme = {
spacing: { 4: 16, 6: 24 },
fontSize: { lg: 18 },
colors: {
surface0: "#111",
foregroundMuted: "#999",
},
};
return {
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
};
});
vi.mock("@react-navigation/native", () => ({
useIsFocused: () => true,
}));
vi.mock("expo-router", () => ({
router: {
navigate,
},
}));
vi.mock("lucide-react-native", () => ({
ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }),
}));
vi.mock("@/components/headers/menu-header", () => ({
MenuHeader: ({ title }: { title: string }) =>
React.createElement("header", { "data-testid": "menu-header" }, title),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) =>
React.createElement("button", { onClick: onPress }, children),
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ color, size }: { color: string; size?: string }) =>
React.createElement("div", {
"data-color": color,
"data-size": size,
"data-testid": "sessions-loading-spinner",
}),
}));
vi.mock("@/components/agent-list", () => ({
AgentList: ({ agents }: { agents: unknown[] }) =>
React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }),
}));
vi.mock("@/hooks/use-agent-history", () => ({
useAgentHistory: () => {
if (!historyResult.current) {
throw new Error("Expected history result");
}
return historyResult.current;
},
}));
vi.mock("@/utils/host-routes", () => ({
buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`,
}));
function makeHistoryResult(overrides: Partial<AgentHistoryResult> = {}): AgentHistoryResult {
return {
agents: [],
isLoading: false,
isInitialLoad: false,
isRevalidating: false,
hasMore: false,
isLoadingMore: false,
refreshAll: vi.fn(),
loadMore: vi.fn(),
...overrides,
};
}
describe("SessionsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
navigate.mockReset();
historyResult.current = makeHistoryResult();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
historyResult.current = null;
vi.unstubAllGlobals();
});
it("shows the shared loader during the initial React Query history load", () => {
historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true });
act(() => {
root?.render(<SessionsScreen serverId="server-1" />);
});
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull();
expect(container?.textContent).not.toContain("No sessions yet");
expect(container?.textContent).not.toContain("Back");
});
it("shows the empty state after history finishes loading with no sessions", () => {
historyResult.current = makeHistoryResult();
act(() => {
root?.render(<SessionsScreen serverId="server-1" />);
});
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
expect(container?.textContent).toContain("No sessions yet");
expect(container?.textContent).toContain("Back");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,270 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComposerAttachment } from "@/attachments/types";
import type { MessagePayload } from "@/components/message-input";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import type { StreamItem } from "@/types/stream";
import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
const {
createAgentMock,
latestComposerText,
latestComposerDisabled,
latestStreamText,
onCreatedMock,
} = vi.hoisted(() => ({
createAgentMock: vi.fn(),
latestComposerText: { current: null as string | null },
latestComposerDisabled: { current: null as boolean | null },
latestStreamText: { current: null as string | null },
onCreatedMock: vi.fn(),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({
colors: {
surface0: "#000",
surface2: "#222",
destructive: "#f00",
},
spacing: { 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6 },
fontSize: { sm: 13 },
})
: factory,
},
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => ({
createAgent: createAgentMock,
}),
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceExecutionAuthority: () => ({
ok: true,
authority: {
workspaceId: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
},
}),
}));
vi.mock("@/hooks/use-agent-input-draft", () => ({
useAgentInputDraft: () => {
const [text, setText] = React.useState("please review this change");
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
return {
text,
setText,
attachments,
setAttachments,
cwd: "/repo/.paseo/worktrees/workspace-1",
clear: () => {
setText("");
setAttachments([]);
},
isHydrated: true,
composerState: {
providerDefinitions: [{ id: "codex", label: "Codex" }],
selectedProvider: "codex",
selectedMode: "",
modeOptions: [],
selectedModel: "gpt-5.4",
availableModels: [{ id: "gpt-5.4", label: "GPT-5.4" }],
allProviderModels: {},
isAllModelsLoading: false,
isModelLoading: false,
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "",
featureValues: undefined,
statusControls: {
providerDefinitions: [{ id: "codex", label: "Codex" }],
selectedProvider: "codex",
modeOptions: [],
selectedMode: "",
models: [{ id: "gpt-5.4", label: "GPT-5.4" }],
selectedModel: "gpt-5.4",
thinkingOptions: [],
selectedThinkingOptionId: "",
isModelLoading: false,
allProviderModels: {},
isAllModelsLoading: false,
features: [],
},
commandDraftConfig: undefined,
persistFormPreferences: vi.fn(),
},
};
},
}));
vi.mock("@/utils/encode-images", () => ({
encodeImages: vi.fn(async () => []),
}));
vi.mock("@/components/composer-attachments", () => ({
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
images: [],
attachments,
}),
}));
vi.mock("@/components/composer", () => ({
Composer: ({
value,
isSubmitLoading,
}: {
onSubmitMessage: (payload: MessagePayload) => Promise<void>;
value: string;
isSubmitLoading?: boolean;
}) => {
latestComposerText.current = value;
latestComposerDisabled.current = isSubmitLoading ?? false;
return (
<textarea
aria-label="Message agent..."
data-testid="composer"
disabled={isSubmitLoading}
readOnly
value={value}
/>
);
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: ({ streamItems }: { streamItems: StreamItem[] }) => {
latestStreamText.current =
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
},
}));
vi.mock("@/components/file-drop-zone", () => ({
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createAgentMock.mockReset();
onCreatedMock.mockReset();
latestComposerText.current = null;
latestComposerDisabled.current = null;
latestStreamText.current = null;
useCreateFlowStore.getState().clearAll();
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {} });
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container?.remove();
container = null;
queryClient = null;
});
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function renderDraftTab() {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<WorkspaceDraftAgentTab
serverId="server"
workspaceId="workspace-1"
tabId="draft-1"
draftId="draft-1"
isPaneFocused={true}
onCreated={onCreatedMock}
onOpenWorkspaceFile={vi.fn()}
/>
</QueryClientProvider>,
);
});
}
describe("WorkspaceDraftAgentTab", () => {
it("clears the composer while an auto-submitted new-workspace prompt is shown in the stream", async () => {
const createAgent = createDeferredPromise<{ id: string }>();
createAgentMock.mockImplementationOnce(async () => await createAgent.promise);
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId: "server",
workspaceId: "workspace-1",
draftId: "draft-1",
text: "please review this change",
attachments: [],
cwd: "/repo/.paseo/worktrees/workspace-1",
provider: "codex",
model: "gpt-5.4",
allowEmptyText: true,
});
renderDraftTab();
await flush();
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(latestStreamText.current).toBe("please review this change");
expect(latestComposerDisabled.current).toBe(true);
expect(latestComposerText.current).toBe("");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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