Compare commits

..

2 Commits

Author SHA1 Message Date
Mohamed Boudra
7c02b9ec7c fix(search): address workspace search review feedback 2026-07-01 19:07:53 +02:00
Mohamed Boudra
9905b0da31 fix(search): include dot-prefixed workspace entries
Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path.
2026-06-30 23:46:32 +02:00
205 changed files with 2940 additions and 21391 deletions

View File

@@ -329,6 +329,18 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Windows arm64 native search packages
shell: bash
run: |
set -euo pipefail
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |

View File

@@ -1,32 +1,5 @@
# Changelog
## 0.1.104-beta.1 - 2026-07-03
### Added
- Agents can drive the in-app browser: open tabs, navigate, click, type, and take screenshots
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
### Improved
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
### Fixed
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
- New workspace action shows for non-git projects in the sidebar ([#1857](https://github.com/getpaseo/paseo/pull/1857) by [@cleiter](https://github.com/cleiter))
## 0.1.103 - 2026-07-01
### Added
- Claude Sonnet 5 is available in the Claude model picker ([#1850](https://github.com/getpaseo/paseo/pull/1850))
## 0.1.102 - 2026-06-30
### Added

View File

@@ -21,35 +21,34 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start

View File

@@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site

View File

@@ -1,49 +0,0 @@
# Browser Capture Harness
The desktop capture harness is the real-Electron verification path for browser screenshots.
It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest has no copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack, and the capture
target is raised above its sibling webviews before capture;
- a newly attached resident webview whose first useful frame is delayed can be captured
by holding prep active and retrying until the frame appears;
- the app-style prep sequence moves the host to a paintable 1x1 clipped state, waits two animation frames and a layout read, then both viewport `capturePage` and full-page CDP screenshots return real pixels;
- restore returns the host to offscreen parking after every capture.
Run it with the repo Electron:
```bash
npm run capture-harness --workspace=@getpaseo/desktop
```
The harness writes PNG evidence and `results.json` to:
```text
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for both guest sizes, the expected parked-capture
failure, the legacy stacked-below-the-clip failure for the second webview, five viewport
prep captures and five full-page prep captures for each of the first two webviews,
fresh-delayed-first-frame viewport and full-page captures for a newly attached webview,
and final completion. The PNG sizes may be device-pixel scaled; on a Retina display the
1280x800 logical viewport is usually saved as 2560x1600.
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
webview parked at `left:-20000px` and `opacity:0` does not have a copyable surface, and
`capturePage({ stayHidden:false })` or CDP `Page.captureScreenshot` cannot rescue it.
Before pixel capture, the app renderer temporarily makes the resident host paintable:
`left:0`, `top:0`, `opacity:1`, `pointer-events:none`, host size `1x1`, and
`overflow:hidden`, with the full-size 1280x800 webview inside. Resident webviews are
parked absolutely at `0,0` inside that host because a second webview stacked below the
1px clip still has no copyable surface. During capture, the renderer raises the target
webview above the other resident webviews; an overlay or sibling above the target can make
full-page CDP capture fail. Main captures only after the renderer acknowledges two
animation frames plus a `getBoundingClientRect()` read, and the renderer restores parking
in a `finally`.

View File

@@ -72,10 +72,6 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
When running a dedicated Electron QA instance against a non-default Expo port, set
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
### React render profiling
The app has a gated React render profiler in
@@ -173,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Workspace search
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
matcher in Paseo. If ignore semantics need to change, change the backend
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array

View File

@@ -47,27 +47,18 @@ dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, express only the destination with `navigateToWorkspace()`.
Do not make the caller branch on its current route.
When app-wide routes such as `/new` navigate back into a host workspace, use
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
leaf workspace URL.
The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
root host route and pass the nested workspace screen when a host route is
already mounted, or Expo Router can append extra hidden workspace deck entries.
The workspace navigation helper inspects the mounted navigation state to make
that decision; if no host route is mounted yet, it falls back to ordinary route
navigation.
root host route and pass the nested workspace screen, or Expo Router can append
extra hidden workspace deck entries.
Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen.
Hidden host routes may keep their local params while an app-wide route is
foregrounded. Active-workspace observers must prefer the current pathname and
only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Params
Required dynamic params belong to the matched route.
@@ -121,7 +112,8 @@ Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
- [ ] Did an app-wide route return to a workspace? Use
`navigateToHostWorkspaceRoute()`.
- [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership

View File

@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `opencode`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`

View File

@@ -1 +1 @@
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=

374
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -6154,6 +6154,141 @@
"excpretty": "build/cli.js"
}
},
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-darwin-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-win32-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-bin-win32-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-node": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
"@ff-labs/fff-bin-win32-x64": "0.9.6"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -13761,6 +13896,183 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -21209,6 +21521,25 @@
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
"license": "MIT"
},
"node_modules/ffi-rs": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
"license": "MIT",
"optionalDependencies": {
"@yuuang/ffi-rs-android-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -35132,7 +35463,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.1",
"@getpaseo/protocol": "0.1.104-beta.1",
"@getpaseo/server": "0.1.104-beta.1",
"@getpaseo/client": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/server": "0.1.102",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36732,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.1",
"@getpaseo/relay": "0.1.104-beta.1",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36746,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36989,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37885,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,7 +38117,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37798,7 +38129,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38347,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.1",
"@getpaseo/highlight": "0.1.104-beta.1",
"@getpaseo/protocol": "0.1.104-beta.1",
"@getpaseo/relay": "0.1.104-beta.1",
"@ff-labs/fff-node": "^0.9.6",
"@getpaseo/client": "0.1.102",
"@getpaseo/highlight": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38893,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -1,3 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import {
@@ -5,7 +7,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
@@ -153,13 +155,6 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
}
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
await page.getByTestId("settings-back-to-workspace").click();
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
}
async function expectSingleCurrentWorkspaceDeckEntry(
page: Page,
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
@@ -216,6 +211,25 @@ async function openReadyMockAgent(
}
}
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
}
async function expectFileMentionSuggestion(
page: Page,
query: string,
suggestion: string,
): Promise<void> {
const input = composerLocator(page);
await input.fill(query);
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
@@ -358,7 +372,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("stays visible after returning from app-wide routes", async ({ page }) => {
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
const sessions: MockAgentWorkspace[] = [];
@@ -391,28 +405,10 @@ test.describe("Composer autocomplete", () => {
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSettingsThenBackToWorkspace(page);
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSessions(page);
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: third.workspaceId,
});
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
@@ -544,6 +540,28 @@ test.describe("Composer autocomplete", () => {
}
});
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-dot-entries-",
title: "Dot file mention autocomplete",
});
try {
await seedDotPrefixedWorkspaceFiles(session.cwd);
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await expectFileMentionSuggestion(page, "@.env", ".env.local");
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
} finally {
await session.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);

View File

@@ -5,29 +5,6 @@ import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} 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" || typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
// --- Navigation ---
export async function openProjects(page: Page): Promise<void> {
@@ -194,39 +171,33 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
// --- WebSocket helpers ---
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
// until the test explicitly allows recovery. Closing the transport leaves the
// client-side RPC pending across reconnects, so this injects the same correlated
// rpc_error shape the daemon emits for failed async session requests.
export async function installReadTransportFailure(
page: Page,
): Promise<{ allowRecovery: () => void }> {
let shouldFailReads = true;
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
const requestId = sessionMessage.requestId;
if (typeof requestId === "string") {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "read_project_config_request",
error: "Test read transport failure.",
code: "transport",
},
},
}),
);
if (armed && typeof message === "string") {
try {
const envelope = JSON.parse(message) as {
type?: string;
message?: { type?: string };
};
if (
envelope.type === "session" &&
envelope.message?.type === "read_project_config_request"
) {
armed = false;
void ws.close({ code: 1001 });
return;
}
} catch {
// binary or malformed frame — pass through
}
return;
}
try {
server.send(message);
@@ -243,12 +214,6 @@ export async function installReadTransportFailure(
}
});
});
return {
allowRecovery() {
shouldFailReads = false;
},
};
}
// Installs a transparent WS proxy that can later drop all active daemon connections

View File

@@ -10,51 +10,50 @@ export async function expectCurrentQuestion(
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
// Nav tabs only render for multi-question cards (hidden for a lone question).
if (input.total > 1) {
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
}
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
}
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
// Options render as radios (single-select) or checkboxes (multi-select), so match
// either role by accessible name.
function questionOption(page: Page, option: string) {
const card = page.getByTestId("question-form-card").first();
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
}
// The multi-question nav renders as a tablist; each question is a tab.
function questionNavTab(page: Page, input: { index: number; total: number }) {
return page
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await questionOption(page, option).click();
.getByRole("button", { name: option })
.click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await questionNavTab(page, input).click();
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
}
export async function expectQuestionNavigationEnabled(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await expect(questionNavTab(page, input)).toBeEnabled();
await expect(
page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toBeEnabled();
}
export async function fillQuestionAnswer(

View File

@@ -1,249 +0,0 @@
import { type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
import { type SeededWorkspace } from "./seed-client";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const FAKE_HOST_MODEL_ID = "fake-host-model";
const FAKE_HOST_MODEL_LABEL = "Fake host model";
const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
type WebSocketMessage = string | Buffer;
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
return JSON.stringify({
type: "session",
message: {
type,
payload,
},
});
}
function buildFakeProviderEntries(nowIso: string) {
return [
{
provider: "mock",
label: "Mock",
status: "ready",
enabled: true,
fetchedAt: nowIso,
models: [
{
provider: "mock",
id: FAKE_HOST_MODEL_ID,
label: FAKE_HOST_MODEL_LABEL,
isDefault: true,
},
],
modes: [{ id: "load-test", label: "Load test" }],
defaultModeId: "load-test",
},
];
}
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
const parsed = parseJson(message);
if (!parsed || typeof parsed !== "object") {
return null;
}
const envelope = parsed as { type?: string; message?: SessionRequest };
if (envelope.type !== "session" || !envelope.message) {
return null;
}
return envelope.message;
}
function getRequestId(request: SessionRequest): string {
return typeof request.requestId === "string" ? request.requestId : "fake-request";
}
export async function buildFakeScheduleHostWorkspace(
workspace: SeededWorkspace,
): Promise<FakeScheduleHostWorkspace> {
const workspaceList = await workspace.client.fetchWorkspaces({
filter: { projectId: workspace.projectId },
});
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
if (!baseWorkspace) {
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
}
const projectId = `${workspace.projectId}-fake-host`;
const cwd = `${workspace.repoPath}-fake-host`;
return {
serverId: "schedule-fake-host",
projectId,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
id: `${baseWorkspace.id}-fake-host`,
projectId,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
projectRootPath: cwd,
workspaceDirectory: cwd,
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
project: undefined,
},
};
}
export async function installFakeScheduleHost(input: {
page: Page;
port: string;
serverId: string;
workspace: Record<string, unknown>;
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
ws.onMessage((message) => {
const parsed = parseJson(message);
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
ws.send(
buildSessionMessage("status", {
status: "server_info",
serverId: input.serverId,
hostname: "fake-schedule-host",
version: "0.0.0-e2e",
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectAdd: true,
projectRemove: true,
worktreeRestore: true,
},
}),
);
return;
}
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
const request = readSessionRequest(message);
if (!request) {
return;
}
const requestId = getRequestId(request);
const now = Date.now();
const nowIso = new Date(now).toISOString();
switch (request.type) {
case "ping":
ws.send(
buildSessionMessage("pong", {
requestId,
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
serverReceivedAt: now,
serverSentAt: now,
}),
);
return;
case "fetch_workspaces_request":
ws.send(
buildSessionMessage("fetch_workspaces_response", {
requestId,
entries: [input.workspace],
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
requestId,
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "get_providers_snapshot_request":
ws.send(
buildSessionMessage("get_providers_snapshot_response", {
requestId,
entries: buildFakeProviderEntries(nowIso),
generatedAt: nowIso,
}),
);
return;
case "refresh_providers_snapshot_request":
ws.send(
buildSessionMessage("refresh_providers_snapshot_response", {
requestId,
acknowledged: true,
}),
);
return;
case "schedule/list":
ws.send(
buildSessionMessage("schedule/list/response", {
requestId,
schedules: [],
error: null,
}),
);
return;
}
});
});
}
export async function addFakeScheduleHostAndReload(input: {
page: Page;
serverId: string;
label: string;
port: string;
}): Promise<void> {
const host = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
});
await input.page.evaluate(
({ seededHost, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
localStorage.setItem(keys.registry, JSON.stringify([...registry, seededHost]));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
},
);
await input.page.reload();
}

View File

@@ -10,15 +10,14 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
// Model B entry points into the New Workspace screen. The per-project
// "+ New workspace" sidebar row is gone; the surviving entries are the global
// button (universal) and each git project's own new-worktree icon (preselects
// that project). These specs prove the global entry opens the screen, the
// project icon preselects the right project across the reused 'new' screen, and
// non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
@@ -106,54 +105,6 @@ test.describe("New workspace entry points", () => {
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
const otherProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "aa-new-workspace-archive-other-",
});
const rememberedProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "zz-new-workspace-archive-remembered-",
});
const serverId = getServerId();
const draftText = "keep this new workspace draft";
try {
await seedSavedSettingsHosts(page, [
{
serverId,
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page
.getByTestId(`sidebar-workspace-row-${serverId}:${rememberedProject.workspaceId}`)
.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.goto(`/new?serverId=${encodeURIComponent(serverId)}`);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
await composer.fill(draftText);
await expect(composer).toHaveValue(draftText);
await clickArchiveWorkspaceMenuItem(page, rememberedProject.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, rememberedProject.workspaceId);
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
await expect(composer).toHaveValue(draftText);
await expectNewWorkspaceProjectSelected(page, rememberedProject.projectDisplayName);
} finally {
await otherProject.cleanup();
await rememberedProject.cleanup();
}
});
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
page,
}) => {
@@ -215,7 +166,7 @@ test.describe("New workspace entry points", () => {
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
// select it in the picker (its row has no new-worktree icon).
await openGlobalNewWorkspaceComposer(page);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });

View File

@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
page,
editableProject,
}) => {
// Reject read_project_config_request calls until the user clicks Reload.
// This keeps automatic reconnect refetches from racing past the callout.
const transportFailure = await installReadTransportFailure(page);
// Drop the WS connection the moment a read_project_config_request is sent.
// Subsequent connections are proxied transparently so Reload can succeed.
await installReadTransportFailure(page);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
@@ -292,8 +292,7 @@ test.describe("Projects settings — error UX", () => {
await expectProjectSettingsError(page, "transport");
await expectProjectSettingsFormHidden(page);
// Retry Reload until the refetch wins any in-flight error-state rendering.
transportFailure.allowRecovery();
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
await expect(async () => {
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "transport", 3_000);

View File

@@ -1,92 +0,0 @@
import { expect, test } from "./fixtures";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleSeedClient {
scheduleCreate(input: {
prompt: string;
name?: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title: string;
};
};
runOnCreate: boolean;
}): Promise<{ schedule: { id: string } | null; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
name,
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
modeId: "load-test",
title: name,
},
},
runOnCreate: false,
});
if (!result.schedule) {
throw new Error(result.error ?? "Failed to seed schedule");
}
return result.schedule.id;
}
function ignoreScheduleDeleteError(): void {}
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
await (workspace.client as unknown as ScheduleSeedClient)
.scheduleDelete({ id })
.catch(ignoreScheduleDeleteError);
}
test.describe("Schedules", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const cleanup of cleanupTasks.toReversed()) {
await cleanup();
}
cleanupTasks.length = 0;
});
test("edit form hydrates the scheduled model selection", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Hydrate model ${Date.now()}`;
const scheduleId = await seedMockSchedule(workspace, scheduleName);
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
workspace.projectDisplayName,
{ timeout: 30_000 },
);
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
timeout: 30_000,
});
});
});

View File

@@ -1,153 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleListItem {
id: string;
name: string | null;
target: { type: string; config?: { cwd?: string } };
}
interface ScheduleSeedClient {
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function selectModelByLabel(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: /select model/i }).click();
const popup = page.getByTestId("combobox-desktop-container");
await expect(popup).toBeVisible({ timeout: 30_000 });
await popup.getByText(label, { exact: true }).click();
await expect(popup).toHaveCount(0, { timeout: 30_000 });
}
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === name);
if (schedule) {
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
}
}
async function expectScheduleCreatedForProject(input: {
workspace: SeededWorkspace;
name: string;
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
expect(schedule).toEqual(
expect.objectContaining({
name: input.name,
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
}),
}),
}),
);
}
test.describe("Schedules project target", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const cleanup of cleanupTasks.toReversed()) {
await cleanup();
}
cleanupTasks.length = 0;
});
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Project schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Summarize the project status.");
await page.getByRole("button", { name: "Cron" }).click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
});
test("clears the selected model when the chosen project moves to another host", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await selectModelByLabel(page, "Ten second stream");
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
fakeHost.projectDisplayName,
);
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
await page.getByLabel("Prompt").fill("Run on the fake host project.");
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
});
});

View File

@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
test.describe("Model B sidebar shape", () => {
test.describe.configure({ timeout: 180_000 });
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
page,
}) => {
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
@@ -62,17 +62,14 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
// The per-project "+ New workspace" row is gone. The git project keeps a
// per-row new-worktree icon (revealed on hover); the non-git project has
// none, since worktree creation needs a git checkout.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
// The global new-workspace button is the universal entry — present for both
// kinds regardless of their per-row affordance.

View File

@@ -256,7 +256,9 @@ test.describe("Workspace navigation regression", () => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
await page.goto(
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expectHostConnectingOrOffline(page);
await expectMenuButtonVisible(page);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.1",
"version": "0.1.102",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -87,7 +87,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
@@ -134,13 +133,14 @@ 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) {
navigateToAgent({ serverId, agentId, pin: true });
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
return;
}
@@ -313,9 +313,6 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
if (hasConfiguredLocalDaemonOverride()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
@@ -892,7 +889,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
(pathname === "/open-project" ||
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
@@ -951,7 +947,6 @@ function RootStack() {
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -17,6 +17,7 @@ export default function HostAgentReadyRoute() {
function HostAgentReadyRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -52,9 +53,10 @@ function HostAgentReadyRouteContent() {
navigateToAgent({
serverId,
agentId,
currentPathname: pathname,
});
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -94,6 +96,7 @@ function HostAgentReadyRouteContent() {
serverId,
agentId,
workspaceId,
currentPathname: pathname,
});
return;
}
@@ -111,7 +114,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
}, [agentId, client, isConnected, pathname, router, serverId]);
return null;
}

View File

@@ -1,10 +0,0 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SchedulesScreen } from "@/screens/schedules-screen";
export default function SchedulesRoute() {
return (
<HostRouteBootstrapBoundary>
<SchedulesScreen />
</HostRouteBootstrapBoundary>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -42,14 +42,6 @@ export interface BrowserElementAttachment {
} | null;
parentChain: string[];
children: string[];
/** Free-text review note the user wrote about this element, if any. */
comment?: string;
/**
* Cropped screenshot of the selected element, sent to the agent as an image
* alongside the textual element context. Persisted via the attachment store;
* referenced by id so the draft-store GC keeps it alive.
*/
screenshot?: AttachmentMetadata;
formatted: string;
}

View File

@@ -1,474 +0,0 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
class FakeStateStorage implements StateStorage {
private readonly values = new Map<string, string>();
public getItem = (key: string): string | null => this.values.get(key) ?? null;
public setItem = (key: string, value: string): void => {
this.values.set(key, value);
};
public removeItem = (key: string): void => {
this.values.delete(key);
};
public clear(): void {
this.values.clear();
}
}
class FakeDaemonClient {
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
public on(
type: "browser.automation.execute.request",
handler: (request: BrowserAutomationExecuteRequest) => void,
): () => void {
expect(type).toBe("browser.automation.execute.request");
this.handler = handler;
return () => {
if (this.handler === handler) {
this.handler = null;
}
};
}
public sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void {
this.sentResponses.push(response);
}
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
this.handler?.(nextRequest);
}
public payloadAt(index: number): BrowserAutomationResponsePayload {
const response = this.sentResponses[index];
if (!response) {
throw new Error(`Missing browser automation response at index ${index}`);
}
return response.payload;
}
}
class FakeBrowserBridge {
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
[];
public readonly activeWorkspaceBrowsers: Array<{
browserId: string | null;
workspaceId: string;
}> = [];
public response: BrowserAutomationResponsePayload | null = null;
public thrownError: unknown = null;
public executeAutomationCommand = async (
request: BrowserAutomationExecuteRequest,
): Promise<BrowserAutomationResponsePayload> => {
this.executedRequests.push(request);
if (this.thrownError) {
throw this.thrownError;
}
return this.response ?? currentListTabsPayload(request.requestId);
};
public registerWorkspaceBrowser = async (input: {
browserId: string;
workspaceId: string;
}): Promise<void> => {
this.registeredWorkspaceBrowsers.push(input);
};
public setWorkspaceActiveBrowser = async (input: {
browserId: string | null;
workspaceId: string;
}): Promise<void> => {
this.activeWorkspaceBrowsers.push(input);
};
}
class FakeResidentBrowser {
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
this.ensuredWebviews.push(input);
return null;
};
}
class BrowserAutomationHandlerHarness {
public readonly client = new FakeDaemonClient();
public readonly browser = new FakeBrowserBridge();
public readonly resident = new FakeResidentBrowser();
private unsubscribe: (() => void) | null = null;
public mount(
input: {
serverId?: string;
host?: DesktopHostBridge | null;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
} = {},
): void {
this.unsubscribe = mountBrowserAutomationHandler({
client: this.client,
...(input.serverId ? { serverId: input.serverId } : {}),
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
ensureResidentBrowserWebview: this.resident.ensure,
...(input.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
: {}),
...(input.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
: {}),
});
}
public unmount(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
public receive(request: BrowserAutomationExecuteRequest): void {
this.client.receive(request);
}
}
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-1",
command: { command: "list_tabs", args: {} },
};
}
function browserNewTabRequest(): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-new",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "new_tab",
args: { url: "https://example.com" },
},
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true,
result: {
command: "list_tabs",
tabs: [],
},
};
}
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
ok: true,
result: {
command: "list_tabs",
tabs: currentBrowserTabs(),
},
};
}
function currentBrowserTabs() {
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
browserId: browser.browserId,
workspaceId: "wks_workspace_a",
url: browser.url,
title: browser.title,
isActive: true,
isLoading: false,
}));
}
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
expect(payload).toMatchObject({
requestId: "req-new",
ok: true,
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
});
if (!payload.ok || payload.result.command !== "new_tab") {
throw new Error("Expected browser_new_tab success payload");
}
return payload.result;
}
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
return useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function waitForRegistrationTimeout(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
const browserAutomationStorage = new FakeStateStorage();
useBrowserStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
useWorkspaceLayoutStore.persist.setOptions({
storage: createJSONStorage(() => browserAutomationStorage),
});
describe("mountBrowserAutomationHandler", () => {
beforeEach(() => {
browserAutomationStorage.clear();
useBrowserStore.setState({ browsersById: {} });
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
test("browser_new_tab creates and focuses a workspace browser tab", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(openedTabs).toEqual([
expect.objectContaining({
target: { kind: "browser", browserId: result.browserId },
}),
]);
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: openedTabs[0]?.tabId }),
}),
);
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.resident.ensuredWebviews).toEqual([
{ browserId: result.browserId, url: "https://example.com" },
]);
expect(browser.browser.executedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-new:list_tabs",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: { command: "list_tabs", args: {} },
},
]);
});
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = emptyListTabsPayload();
browser.mount({
serverId: "server-1",
registrationWaitTimeoutMs: 1,
registrationPollIntervalMs: 1,
});
browser.receive(browserNewTabRequest());
await waitForRegistrationTimeout();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_timeout",
message: expect.stringContaining("Timed out waiting for browser tab"),
retryable: true,
},
},
},
]);
expect(browser.resident.ensuredWebviews).toEqual([
expect.objectContaining({ url: "https://example.com" }),
]);
});
test("browser_new_tab wraps registration bridge errors in a response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error("IPC registration check failed");
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-new",
ok: false,
error: {
code: "browser_unknown_error",
message: "IPC registration check failed",
retryable: false,
},
},
},
]);
});
test("non-new-tab requests send the desktop bridge response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = {
requestId: "desktop-req",
ok: true,
result: { command: "list_tabs", tabs: [] },
};
browser.mount();
const request = browserAutomationRequest();
browser.receive(request);
await flushAsyncWork();
expect(browser.browser.executedRequests).toEqual([request]);
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
},
},
]);
});
test("missing desktop bridge sends browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ host: null });
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
retryable: false,
},
},
},
]);
});
test("typed bridge errors become failure responses", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
};
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: "Browser tab browser-1 was not found.",
retryable: false,
},
},
},
]);
});
test("unimplemented preload IPC reports browser_unsupported", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.thrownError = new Error(
'No handler registered for "paseo:browser:execute-automation-command"',
);
browser.mount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.client.sentResponses).toEqual([
{
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
retryable: false,
},
},
},
]);
});
test("unsubscribe stops handling browser automation requests", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount();
browser.unmount();
browser.receive(browserAutomationRequest());
await flushAsyncWork();
expect(browser.browser.executedRequests).toEqual([]);
expect(browser.client.sentResponses).toEqual([]);
});
});

View File

@@ -1,325 +0,0 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import {
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
installResidentBrowserCaptureBridge,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
type BrowserAutomationFailurePayload = Extract<BrowserAutomationResponsePayload, { ok: false }>;
type BrowserAutomationErrorCode = BrowserAutomationFailurePayload["error"]["code"];
interface BrowserAutomationClient {
on(
type: "browser.automation.execute.request",
handler: (message: BrowserAutomationExecuteRequest) => void,
): () => void;
sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void;
}
export interface BrowserAutomationHandlerOptions {
client: BrowserAutomationClient;
serverId?: string;
getHost?: () => DesktopHostBridge | null;
ensureResidentBrowserWebview?: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}
export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions,
): () => void {
const getHost = options.getHost ?? getDesktopHost;
const uninstallCaptureBridge = installResidentBrowserCaptureBridge();
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({
client: options.client,
getHost,
request,
serverId: options.serverId,
ensureResidentBrowserWebview:
options.ensureResidentBrowserWebview ?? ensureResidentBrowserWebviewDefault,
...(options.registrationWaitTimeoutMs !== undefined
? { registrationWaitTimeoutMs: options.registrationWaitTimeoutMs }
: {}),
...(options.registrationPollIntervalMs !== undefined
? { registrationPollIntervalMs: options.registrationPollIntervalMs }
: {}),
});
});
return () => {
unsubscribe();
uninstallCaptureBridge();
};
}
export function mountBrowserAutomationDaemonClientHandler(
client: unknown,
options?: { serverId?: string },
): () => void {
return mountBrowserAutomationHandler({
client: client as BrowserAutomationClient,
...(options?.serverId ? { serverId: options.serverId } : {}),
});
}
async function handleBrowserAutomationRequest(params: {
client: BrowserAutomationHandlerOptions["client"];
getHost: () => DesktopHostBridge | null;
request: BrowserAutomationExecuteRequest;
serverId?: string;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<void> {
const {
client,
getHost,
request,
serverId,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const browserHost = getHost()?.browser;
const executeAutomationCommand = browserHost?.executeAutomationCommand;
if (request.command.command === "new_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await openBrowserTabForRequest({
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
...(registrationWaitTimeoutMs !== undefined ? { registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined ? { registrationPollIntervalMs } : {}),
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
}),
});
return;
}
try {
const payload = await executeAutomationCommand(request);
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeBridgePayload(request.requestId, payload),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
}
async function openBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
ensureResidentBrowserWebview: typeof ensureResidentBrowserWebviewDefault;
registrationWaitTimeoutMs?: number;
registrationPollIntervalMs?: number;
}): Promise<BrowserAutomationResponsePayload> {
const {
request,
serverId,
browserHost,
ensureResidentBrowserWebview,
registrationWaitTimeoutMs,
registrationPollIntervalMs,
} = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "new_tab" }
>;
const workspaceId = request.workspaceId;
if (!serverId || !workspaceId) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
const url = command.args.url ?? "https://example.com";
const { browserId, url: normalizedUrl } = createWorkspaceBrowser({ initialUrl: url });
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!workspaceKey) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
kind: "browser",
browserId,
});
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
await browserHost?.setWorkspaceActiveBrowser?.({ browserId, workspaceId });
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
const registered = await waitForBrowserRegistration({
request,
browserId,
workspaceId,
executeAutomationCommand: browserHost.executeAutomationCommand,
...(registrationWaitTimeoutMs !== undefined ? { timeoutMs: registrationWaitTimeoutMs } : {}),
...(registrationPollIntervalMs !== undefined
? { pollIntervalMs: registrationPollIntervalMs }
: {}),
});
if (!registered) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
retryable: true,
});
}
}
return {
requestId: request.requestId,
ok: true,
result: { command: "new_tab", browserId, workspaceId, url: normalizedUrl },
};
}
async function waitForBrowserRegistration(params: {
request: BrowserAutomationExecuteRequest;
browserId: string;
workspaceId: string;
executeAutomationCommand: (
request: BrowserAutomationExecuteRequest,
) => Promise<BrowserAutomationResponsePayload>;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const deadline = Date.now() + (params.timeoutMs ?? 5_000);
while (Date.now() < deadline) {
const payload = await params.executeAutomationCommand({
type: "browser.automation.execute.request",
requestId: `${params.request.requestId}:list_tabs`,
agentId: params.request.agentId,
cwd: params.request.cwd,
workspaceId: params.workspaceId,
command: { command: "list_tabs", args: {} },
});
if (payload.ok && payload.result.command === "list_tabs") {
if (payload.result.tabs.some((tab) => tab.browserId === params.browserId)) {
return true;
}
}
await delay(params.pollIntervalMs ?? 100);
}
return false;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeBridgePayload(
requestId: string,
payload: BrowserAutomationResponsePayload,
): BrowserAutomationResponsePayload {
return { ...payload, requestId } as BrowserAutomationResponsePayload;
}
function normalizeThrownBridgeError(
requestId: string,
error: unknown,
): BrowserAutomationFailurePayload {
const typed = readTypedBrowserAutomationError(error);
if (typed) {
return browserAutomationFailure({ requestId, ...typed });
}
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered")) {
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Desktop browser automation failed.",
});
}
function readTypedBrowserAutomationError(
value: unknown,
): { code: BrowserAutomationErrorCode; message: string; retryable?: boolean } | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.code !== "string" || !record.code.startsWith("browser_")) {
return null;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return null;
}
return {
code: record.code as BrowserAutomationErrorCode,
message: record.message,
...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}),
};
}
function browserAutomationFailure(params: {
requestId: string;
code: BrowserAutomationErrorCode;
message: string;
retryable?: boolean;
}): BrowserAutomationFailurePayload {
return {
requestId: params.requestId,
ok: false,
error: {
code: params.code,
message: params.message,
retryable: params.retryable ?? false,
},
};
}

View File

@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -21,7 +21,6 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -179,11 +178,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
desktopScrollContainer: {
flexShrink: 1,
minHeight: 0,
position: "relative",
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
@@ -457,11 +451,6 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/**
* Render the themed desktop-web scrollbar over the scroll area instead of the
* native browser scrollbar. No-op on native and on the mobile bottom sheet.
*/
webScrollbar?: boolean;
}
export function AdaptiveModalSheet({
@@ -475,16 +464,11 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
webScrollbar = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const desktopScrollRef = useRef<ScrollView>(null);
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
enabled: webScrollbar && !isMobile,
});
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
@@ -590,22 +574,13 @@ export function AdaptiveModalSheet({
<>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<View style={styles.desktopScrollContainer}>
<ScrollView
ref={desktopScrollRef}
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
onLayout={desktopScrollbar.onLayout}
onScroll={desktopScrollbar.onScroll}
onContentSizeChange={desktopScrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!webScrollbar}
>
{children}
</ScrollView>
{desktopScrollbar.overlay}
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,300 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
cancelResidentBrowserWebviewPixelCapture,
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
prepareBrowserWebview,
prepareResidentBrowserWebviewForPixelCapture,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
restoreResidentBrowserWebviewAfterPixelCapture,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
describe("resident browser webviews", () => {
afterEach(() => {
clearResidentBrowserWebviewsForTests();
});
it("keeps a browser webview mounted offscreen and reuses the same node", () => {
const host = document.createElement("div");
const webview = document.createElement("webview");
host.appendChild(webview);
document.body.appendChild(host);
releaseResidentBrowserWebview("browser-a", webview);
expect(host.children).toHaveLength(0);
expect(webview.isConnected).toBe(true);
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
const reused = takeResidentBrowserWebview("browser-a");
expect(reused).toBe(webview);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
});
it("creates a resident webview for an agent-created unfocused tab", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-agent",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expect(webview?.isConnected).toBe(true);
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com",
);
});
it("removes a resident webview when its browser tab closes", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-closed",
url: "https://example.com",
});
removeResidentBrowserWebview("browser-closed");
expect(webview?.isConnected).toBe(false);
expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
});
it("temporarily makes resident webviews paintable for pixel capture", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-capture",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident browser webview");
}
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-capture",
});
const host = document.getElementById("paseo-browser-resident-webviews");
expect(preparation.token).toBe("capture-1");
expect(host?.style.left).toBe("0px");
expect(host?.style.top).toBe("0px");
expect(host?.style.width).toBe("1px");
expect(host?.style.height).toBe("1px");
expect(host?.style.overflow).toBe("hidden");
expect(host?.style.opacity).toBe("1");
expect(host?.style.pointerEvents).toBe("none");
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("2");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.height).toBe("800px");
expect(host?.style.opacity).toBe("0");
expect(webview.style.zIndex).toBe("0");
});
it("parks resident webviews as an overlapping stack and raises the capture target", async () => {
const firstWebview = ensureResidentBrowserWebview({
browserId: "browser-first",
url: "https://example.com/first",
});
const secondWebview = ensureResidentBrowserWebview({
browserId: "browser-second",
url: "https://example.com/second",
});
if (!firstWebview || !secondWebview) {
throw new Error("Expected resident browser webviews");
}
expect(firstWebview.style.position).toBe("absolute");
expect(firstWebview.style.left).toBe("0px");
expect(firstWebview.style.top).toBe("0px");
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.position).toBe("absolute");
expect(secondWebview.style.left).toBe("0px");
expect(secondWebview.style.top).toBe("0px");
expect(secondWebview.style.zIndex).toBe("0");
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-second",
});
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.zIndex).toBe("2");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.zIndex).toBe("0");
});
it("prepares the physically resident webview when a focused duplicate exists outside the resident host", async () => {
const residentWebview = ensureResidentBrowserWebview({
browserId: "browser-focused-undisplayed",
url: "https://example.com/resident",
});
if (!residentWebview) {
throw new Error("Expected resident browser webview");
}
const visibleHost = document.createElement("div");
const duplicateWebview = document.createElement("webview");
prepareBrowserWebview(duplicateWebview, {
browserId: "browser-focused-undisplayed",
initialUrl: "https://example.com/duplicate",
});
visibleHost.appendChild(duplicateWebview);
document.body.prepend(visibleHost);
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-focused-undisplayed",
});
const residentHost = document.getElementById("paseo-browser-resident-webviews");
expect(residentHost?.style.left).toBe("0px");
expect(residentHost?.style.width).toBe("1px");
expect(residentWebview.style.zIndex).toBe("2");
expect(duplicateWebview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
});
it("does not apply resident prep styles for a genuinely visible pane webview", async () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareBrowserWebview(visibleWebview, {
browserId: "browser-visible-pane",
initialUrl: "https://example.com",
});
visibleWebview.style.display = "flex";
visibleWebview.style.width = "100%";
visibleWebview.style.height = "100%";
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-visible-pane",
});
const residentHost = document.getElementById("paseo-browser-resident-webviews");
expect(residentHost?.style.left).toBe("-20000px");
expect(residentHost?.style.width).toBe("1280px");
expect(visibleWebview.style.position).toBe("");
expect(visibleWebview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
});
it("clears capture preparation when a resident webview is taken visible", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident browser webview");
}
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-visible",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
});
it("keeps the resident host paintable until every capture token is restored", async () => {
ensureResidentBrowserWebview({
browserId: "browser-overlap",
url: "https://example.com",
});
const first = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-overlap",
});
const second = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-overlap",
});
const host = document.getElementById("paseo-browser-resident-webviews");
await restoreResidentBrowserWebviewAfterPixelCapture(first);
expect(host?.style.left).toBe("0px");
expect(host?.style.width).toBe("1px");
expect(host?.style.opacity).toBe("1");
await restoreResidentBrowserWebviewAfterPixelCapture(second);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("cancels an in-flight pixel capture preparation by request id", async () => {
ensureResidentBrowserWebview({
browserId: "browser-cancel",
url: "https://example.com",
});
const preparation = prepareResidentBrowserWebviewForPixelCapture({
requestId: "prepare-1",
browserId: "browser-cancel",
});
const host = document.getElementById("paseo-browser-resident-webviews");
expect(host?.style.left).toBe("0px");
expect(host?.style.opacity).toBe("1");
await cancelResidentBrowserWebviewPixelCapture({ requestId: "prepare-1" });
await expect(preparation).rejects.toThrow("Browser pixel capture preparation was canceled.");
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("parks the resident host when a prepared browser tab is removed", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-detached",
url: "https://example.com",
});
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-detached",
});
const host = document.getElementById("paseo-browser-resident-webviews");
removeResidentBrowserWebview("browser-detached");
expect(webview?.isConnected).toBe(false);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(host?.style.left).toBe("-20000px");
});
});

View File

@@ -1,429 +0,0 @@
import { getDesktopHost } from "@/desktop/host";
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const activeCapturePreparations = new Map<string, ActiveCapturePreparation>();
let captureBridgeInstallCount = 0;
let captureBridgeDisposer: (() => void) | null = null;
let nextCapturePreparationId = 0;
interface BrowserWebviewElement extends HTMLElement {
src: string;
}
interface ActiveCapturePreparation {
browserId: string;
requestId?: string;
preparesResidentHost: boolean;
webview: HTMLElement;
}
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 readDocument(): Document | null {
return typeof document === "undefined" ? null : document;
}
function applyResidentHostParkingStyle(host: HTMLElement): void {
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyResidentHostCaptureStyle(host: HTMLElement): void {
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "0";
host.style.top = "0";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "1";
host.style.pointerEvents = "none";
host.style.zIndex = "1";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) {
return existing;
}
const host = ownerDocument.createElement("div");
host.id = RESIDENT_BROWSER_HOST_ID;
applyResidentHostParkingStyle(host);
ownerDocument.body.appendChild(host);
return host;
}
function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLElement | null {
for (const element of ownerDocument.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
return element;
}
}
return null;
}
function findBrowserWebviewForPixelCapture(
browserId: string,
ownerDocument: Document,
): HTMLElement | null {
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
return resident;
}
return findBrowserWebview(browserId, ownerDocument);
}
function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = "0";
}
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = "";
webview.style.zIndex = "";
}
function residentWebviewChildren(host: HTMLElement): HTMLElement[] {
const webviews: HTMLElement[] = [];
for (const element of host.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (element instanceof HTMLElement) {
webviews.push(element);
}
}
return webviews;
}
function raiseResidentWebviewForCapture(host: HTMLElement, target: HTMLElement): void {
for (const webview of residentWebviewChildren(host)) {
applyResidentWebviewStyle(webview);
}
applyResidentWebviewStyle(target);
target.style.zIndex = "2";
}
function nextAnimationFrame(): Promise<void> {
if (typeof requestAnimationFrame !== "function") {
return Promise.resolve();
}
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve();
});
});
}
async function waitForCapturePaint(webview: HTMLElement): Promise<void> {
await nextAnimationFrame();
await nextAnimationFrame();
webview.getBoundingClientRect();
}
function activeCaptureTokenFor(input: { token?: string; requestId?: string }): string | null {
const token = trimNonEmpty(input.token);
if (token && activeCapturePreparations.has(token)) {
return token;
}
const requestId = trimNonEmpty(input.requestId);
if (!requestId) {
return null;
}
for (const [candidateToken, preparation] of activeCapturePreparations.entries()) {
if (preparation.requestId === requestId) {
return candidateToken;
}
}
return null;
}
function latestActiveResidentHostPreparation(): ActiveCapturePreparation | null {
let latest: ActiveCapturePreparation | null = null;
for (const preparation of activeCapturePreparations.values()) {
if (preparation.preparesResidentHost) {
latest = preparation;
}
}
return latest;
}
function syncResidentHostCaptureState(): void {
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (!(host instanceof HTMLElement)) {
return;
}
const latest = latestActiveResidentHostPreparation();
if (latest) {
applyResidentHostCaptureStyle(host);
raiseResidentWebviewForCapture(host, latest.webview);
return;
}
applyResidentHostParkingStyle(host);
for (const webview of residentWebviewChildren(host)) {
applyResidentWebviewStyle(webview);
}
}
function releaseCapturePreparationToken(token: string): void {
const preparation = activeCapturePreparations.get(token);
if (!preparation) {
return;
}
activeCapturePreparations.delete(token);
if (preparation.preparesResidentHost) {
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement && preparation.webview.parentElement === host) {
applyResidentWebviewStyle(preparation.webview);
}
syncResidentHostCaptureState();
}
}
function releaseCapturePreparationsForBrowser(browserId: string): void {
for (const [token, preparation] of activeCapturePreparations.entries()) {
if (preparation.browserId === browserId) {
activeCapturePreparations.delete(token);
}
}
syncResidentHostCaptureState();
}
function releaseAllCapturePreparations(): void {
activeCapturePreparations.clear();
syncResidentHostCaptureState();
}
export function prepareBrowserWebview(
webview: HTMLElement,
input: { browserId: string; initialUrl?: string | null },
): void {
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
if (input.initialUrl) {
(webview as BrowserWebviewElement).src = input.initialUrl;
}
}
export function ensureResidentBrowserWebview(input: {
browserId: string;
url: string;
}): HTMLElement | null {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
return null;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return null;
}
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
return resident;
}
const existing = findBrowserWebview(browserId, ownerDocument);
if (existing) {
if (existing.parentElement?.id === RESIDENT_BROWSER_HOST_ID) {
residentWebviewsByBrowserId.set(browserId, existing);
}
return existing;
}
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
releaseResidentBrowserWebview(browserId, webview);
return webview;
}
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const webview = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (!webview) {
return null;
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
export function releaseResidentBrowserWebview(browserId: string, webview: HTMLElement): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
webview.remove();
return;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return;
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview);
getResidentBrowserHost(ownerDocument).appendChild(webview);
}
export async function prepareResidentBrowserWebviewForPixelCapture(input: {
browserId: string;
requestId?: string;
}): Promise<{ token: string }> {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
throw new Error("Browser id is required for pixel capture preparation.");
}
const ownerDocument = readDocument();
if (!ownerDocument) {
throw new Error("Browser pixel capture preparation requires a document.");
}
const host = getResidentBrowserHost(ownerDocument);
const webview = findBrowserWebviewForPixelCapture(browserId, ownerDocument);
if (!webview) {
throw new Error(`Browser webview ${browserId} is not mounted.`);
}
const token = `capture-${++nextCapturePreparationId}`;
const preparesResidentHost = webview.parentElement === host;
const requestId = trimNonEmpty(input.requestId);
activeCapturePreparations.set(token, {
browserId,
...(requestId ? { requestId } : {}),
preparesResidentHost,
webview,
});
try {
if (preparesResidentHost) {
applyResidentHostCaptureStyle(host);
raiseResidentWebviewForCapture(host, webview);
}
await waitForCapturePaint(webview);
if (!activeCapturePreparations.has(token)) {
throw new Error("Browser pixel capture preparation was canceled.");
}
return { token };
} catch (error) {
releaseCapturePreparationToken(token);
throw error;
}
}
export async function restoreResidentBrowserWebviewAfterPixelCapture(input: {
token: string;
}): Promise<void> {
releaseCapturePreparationToken(input.token);
}
export async function cancelResidentBrowserWebviewPixelCapture(input: {
requestId?: string;
token?: string;
}): Promise<void> {
const token = activeCaptureTokenFor(input);
if (!token) {
return;
}
releaseCapturePreparationToken(token);
}
export function installResidentBrowserCaptureBridge(): () => void {
captureBridgeInstallCount += 1;
if (!captureBridgeDisposer) {
const browserBridge = getDesktopHost()?.browser;
const disposePrepare = browserBridge?.onPrepareForPixelCapture?.(
prepareResidentBrowserWebviewForPixelCapture,
);
const disposeRestore = browserBridge?.onRestorePixelCapture?.(
restoreResidentBrowserWebviewAfterPixelCapture,
);
const disposeCancel = browserBridge?.onCancelPixelCapture?.(
cancelResidentBrowserWebviewPixelCapture,
);
captureBridgeDisposer = () => {
disposePrepare?.();
disposeRestore?.();
disposeCancel?.();
};
}
return () => {
captureBridgeInstallCount = Math.max(0, captureBridgeInstallCount - 1);
if (captureBridgeInstallCount > 0) {
return;
}
captureBridgeDisposer?.();
captureBridgeDisposer = null;
releaseAllCapturePreparations();
};
}
export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
resident?.remove();
}
export function clearResidentBrowserWebviewsForTests(): void {
for (const webview of residentWebviewsByBrowserId.values()) {
webview.remove();
}
residentWebviewsByBrowserId.clear();
releaseAllCapturePreparations();
nextCapturePreparationId = 0;
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}

View File

@@ -80,8 +80,6 @@ interface CombinedModelSelectorProps {
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
@@ -89,15 +87,6 @@ interface CombinedModelSelectorProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
/**
* Render the custom trigger as a full-width form field: the outer Pressable
* becomes a transparent passthrough that stretches its child edge-to-edge and
* stops painting its own hover/pressed background and rounded corners. The
* trigger itself owns the field visuals and reads hovered/pressed to show its
* active state. Without this the trigger stays a content-width toolbar chip
* (the composer's layout).
*/
triggerFill?: boolean;
}
interface SelectorContentProps {
@@ -585,7 +574,6 @@ export function CombinedModelSelector({
isRetryingProvider = false,
disabled = false,
serverId = null,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -705,26 +693,14 @@ export function CombinedModelSelector({
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
// Fill mode: transparent full-width passthrough. The trigger paints its own
// hover/pressed state from the args, so the wrapper must not double-paint.
if (triggerFill) {
return [
styles.trigger,
styles.customTriggerWrapper,
styles.triggerFill,
disabled && styles.triggerDisabled,
];
}
return [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
];
},
[disabled, isOpen, renderTrigger, triggerFill],
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
],
[disabled, isOpen, renderTrigger],
);
const handleBackToAll = useCallback(() => {
@@ -813,16 +789,12 @@ export function CombinedModelSelector({
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
hovered: Boolean(hovered),
pressed,
})
}
{renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
})}
</Pressable>
) : (
<ComboboxTrigger
@@ -914,16 +886,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
height: "auto",
},
// Stretch the wrapper (and, via column + stretch, its single child) to the
// full width of the field, with no background or rounding of its own.
triggerFill: {
alignSelf: "stretch",
flexShrink: 0,
flexDirection: "column",
alignItems: "stretch",
backgroundColor: "transparent",
borderRadius: 0,
},
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,

View File

@@ -1,118 +0,0 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { HostProfile } from "@/types/host-connection";
import type { Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
HostStatusDotSlot,
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
}
/**
* The "All hosts / <host>" filter pill shared by the History and Schedules
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
* when only one host exists. Copies the History layout exactly.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
triggerTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
}));

View File

@@ -1,15 +1,5 @@
import { router, usePathname } from "expo-router";
import {
CalendarClock,
FolderPlus,
History,
Home,
Plus,
Search,
Server,
Settings,
X,
} from "lucide-react-native";
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -66,7 +56,6 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
@@ -116,7 +105,6 @@ interface SidebarLabels {
switchHost: string;
searchHosts: string;
sessions: string;
schedules: string;
closeSidebar: string;
}
@@ -126,14 +114,12 @@ interface MobileSidebarProps extends SidebarSharedProps {
isOpen: boolean;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({
@@ -235,10 +221,6 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSessionsRoute());
}, []);
const handleViewSchedulesNavigate = useCallback(() => {
router.push(buildSchedulesRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
@@ -249,7 +231,6 @@ export const LeftSidebar = memo(function LeftSidebar({
switchHost: t("sidebar.host.switchTitle"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
schedules: t("sidebar.sections.schedules"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
@@ -286,7 +267,6 @@ export const LeftSidebar = memo(function LeftSidebar({
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
);
}
@@ -302,7 +282,6 @@ export const LeftSidebar = memo(function LeftSidebar({
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
);
});
@@ -578,11 +557,9 @@ function MobileSidebar({
isOpen,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const {
translateX,
backdropOpacity,
@@ -610,13 +587,6 @@ function MobileSidebar({
handleViewMoreNavigate();
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
const handleViewSchedules = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewSchedulesNavigate();
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
@@ -777,14 +747,6 @@ function MobileSidebar({
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
<WorkspacesSectionHeader />
<Pressable
@@ -865,11 +827,9 @@ function DesktopSidebar({
insetsTop,
isOpen,
handleViewMore,
handleViewSchedules,
}: DesktopSidebarProps) {
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -949,14 +909,6 @@ function DesktopSidebar({
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
</View>
<WorkspacesSectionHeader />

View File

@@ -18,7 +18,6 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
it("falls back to the bot icon for unknown custom providers", () => {

View File

@@ -92,49 +92,30 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
// Static left-side control: square for multi-select, circle for single-select.
// Always rendered so toggling only swaps fill/border — the row never reflows.
const controlStyle = useMemo(
() => [
styles.selectionControl,
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
{
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
},
],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
);
const radioDotStyle = useMemo(
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
[theme.colors.accent],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole={multiSelect ? "checkbox" : "radio"}
accessibilityRole="button"
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-checked={isSelected}
aria-selected={isSelected}
>
<View style={styles.optionItemContent}>
<View style={controlStyle}>
{isSelected && multiSelect ? (
<Check size={12} color={theme.colors.accentForeground} />
) : null}
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
</View>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
{option.description ? (
<Text style={optionDescriptionStyle}>{option.description}</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
@@ -143,9 +124,7 @@ function QuestionOptionRow({
interface QuestionNavButtonProps {
index: number;
total: number;
header: string;
isActive: boolean;
isAnswered: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
@@ -153,9 +132,7 @@ interface QuestionNavButtonProps {
function QuestionNavButton({
index,
total,
header,
isActive,
isAnswered,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
@@ -194,7 +171,7 @@ function QuestionNavButton({
return (
<Pressable
accessibilityRole="tab"
accessibilityRole="button"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
@@ -203,61 +180,11 @@ function QuestionNavButton({
onPress={handlePress}
disabled={isResponding}
>
{isAnswered ? (
<Check
size={12}
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : null}
<Text style={textStyle} numberOfLines={1}>
{header}
</Text>
<Text style={textStyle}>{index + 1}</Text>
</Pressable>
);
}
interface QuestionNavProps {
questions: QuestionFormQuestion[];
activeIndex: number;
isAnswered: (qIndex: number) => boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
// Titled tabs (one per question header) with a check on answered ones. Hidden for
// a lone question — a single "1 of 1" tab carries no information.
function QuestionNav({
questions,
activeIndex,
isAnswered,
isResponding,
onSelect,
}: QuestionNavProps) {
if (questions.length <= 1) {
return null;
}
return (
<View
style={styles.questionNav}
testID="question-form-question-nav"
accessibilityRole="tablist"
>
{questions.map((question, qIndex) => (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
header={question.header}
isActive={qIndex === activeIndex}
isAnswered={isAnswered(qIndex)}
isResponding={isResponding}
onSelect={onSelect}
/>
))}
</View>
);
}
interface QuestionOtherInputProps {
qIndex: number;
accessibilityLabel: string;
@@ -428,12 +355,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
setActiveQuestionIndex(index);
}, []);
const navIsAnswered = useCallback(
(qIndex: number) =>
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
[questions, selections, otherTexts],
);
const handlePrimaryAction = useCallback(() => {
if (!isLastQuestion) {
if (!activeQuestionAnswered || isResponding) return;
@@ -486,16 +407,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
// Single-select radios need a group; checkboxes are valid standalone.
const optionsGroupAccessibility = useMemo(
() =>
activeQuestion && !activeQuestion.multiSelect
? ({
accessibilityRole: "radiogroup",
accessibilityLabel: activeQuestion.question,
} as const)
: {},
[activeQuestion],
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
@@ -522,23 +436,33 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return (
<View style={containerStyle} testID="question-form-card">
<QuestionNav
questions={questions}
activeIndex={resolvedActiveQuestionIndex}
isAnswered={navIsAnswered}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
);
})}
</View>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
<View style={styles.optionsWrap}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
@@ -622,6 +546,12 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
@@ -641,24 +571,24 @@ const styles = StyleSheet.create((theme) => ({
},
questionNav: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
},
questionNavButton: {
flexDirection: "row",
minWidth: 28,
height: 28,
alignItems: "center",
gap: theme.spacing[1],
minHeight: 28,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
justifyContent: "center",
borderRadius: 999,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontSize: theme.fontSize.xs,
fontWeight: "700",
},
optionItem: {
flexDirection: "row",
@@ -673,40 +603,25 @@ const styles = StyleSheet.create((theme) => ({
optionItemContent: {
flex: 1,
flexDirection: "row",
alignItems: "flex-start",
alignItems: "center",
gap: theme.spacing[2],
},
optionTextBlock: {
flex: 1,
gap: theme.spacing[1],
gap: 2,
},
optionLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
lineHeight: 22,
fontSize: theme.fontSize.sm,
},
optionDescription: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontSize: theme.fontSize.xs,
lineHeight: 16,
},
selectionControl: {
width: 18,
height: 18,
optionCheckSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
borderWidth: theme.borderWidth[1],
marginTop: 2, // optical-align 18px control to the 22px label first line
},
selectionControlCheckbox: {
borderRadius: theme.borderRadius.base,
},
selectionControlRadio: {
borderRadius: 999,
},
selectionRadioDot: {
width: 8,
height: 8,
borderRadius: 999,
marginLeft: "auto",
},
otherInput: {
borderWidth: 1,

View File

@@ -1,368 +0,0 @@
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
describeCron,
everyMsToParts,
type IntervalUnit,
partsToEveryMs,
validateCron,
} from "@/utils/schedule-format";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
type CadenceMode = ScheduleCadence["type"];
interface CronPreset {
label: string;
expression: string;
}
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
// through describeCron() so the chip and the live preview agree.
const CRON_PRESETS: CronPreset[] = [
{ label: "Every hour", expression: "0 * * * *" },
{ label: "Daily 9:00", expression: "0 9 * * *" },
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
];
const MODE_OPTIONS = [
{ value: "every" as const, label: "Interval" },
{ value: "cron" as const, label: "Cron" },
];
const UNIT_OPTIONS = [
{ value: "minutes" as const, label: "Minutes" },
{ value: "hours" as const, label: "Hours" },
{ value: "days" as const, label: "Days" },
];
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
const UNIT_NOUN: Record<IntervalUnit, string> = {
minutes: "minute",
hours: "hour",
days: "day",
};
function describeInterval(value: number, unit: IntervalUnit): string {
const noun = UNIT_NOUN[unit];
if (value === 1) {
return `Runs every ${noun}`;
}
return `Runs every ${value} ${noun}s`;
}
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
}
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
const mode = value.type;
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
// once from the incoming cadence via lazy state initializers and bump
// resetKey only when we change the content ourselves (mode switch, preset
// chip) — never on every keystroke.
const [intervalValueText, setIntervalValueText] = useState(() =>
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
);
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
);
const [cronText, setCronText] = useState(() =>
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
// Remember the cron expression the user had so toggling Interval -> Cron and
// back does not discard a blanked-out field. Interval mode rebuilds straight
// from the live numeric value + unit, so it needs no equivalent ref.
const lastCronExpression = useRef(
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
const parsedIntervalValue = useMemo(() => {
const parsed = Number.parseInt(intervalValueText, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}, [intervalValueText]);
const emitInterval = useCallback(
(rawValue: number, unit: IntervalUnit) => {
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
},
[onChange],
);
const emitCron = useCallback(
(expression: string) => {
lastCronExpression.current = expression;
onChange({ type: "cron", expression });
},
[onChange],
);
const handleModeChange = useCallback(
(nextMode: CadenceMode) => {
if (nextMode === mode) {
return;
}
if (nextMode === "every") {
emitInterval(parsedIntervalValue, intervalUnit);
} else {
emitCron(cronText.trim() || lastCronExpression.current);
}
},
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
);
const handleIntervalValueChange = useCallback(
(text: string) => {
// Keep only digits so the cadence stays a positive integer count.
const digits = text.replace(/[^0-9]/g, "");
setIntervalValueText(digits);
const parsed = Number.parseInt(digits, 10);
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
},
[emitInterval, intervalUnit],
);
const handleUnitChange = useCallback(
(unit: IntervalUnit) => {
setIntervalUnit(unit);
emitInterval(parsedIntervalValue, unit);
},
[emitInterval, parsedIntervalValue],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
emitCron(text.trim());
},
[emitCron],
);
const handlePresetPress = useCallback(
(expression: string) => {
setCronText(expression);
bumpFieldResetKey();
emitCron(expression);
},
[emitCron],
);
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
const trimmedCron = cronText.trim();
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
let cronFeedback: ReactNode = null;
if (cronError) {
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
} else if (cronPreview) {
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
}
return (
<View style={styles.container}>
<SegmentedControl
size="sm"
value={mode}
onValueChange={handleModeChange}
options={MODE_OPTIONS}
style={styles.modeControl}
testID="cadence-mode"
/>
{mode === "every" ? (
<View style={styles.section}>
<View style={styles.intervalRow}>
<AdaptiveTextInput
testID="cadence-interval-value"
accessibilityLabel="Interval value"
initialValue={intervalValueText}
resetKey={`cadence-interval-${fieldResetKey}`}
value={intervalValueText}
onChangeText={handleIntervalValueChange}
keyboardType="number-pad"
style={styles.intervalInput}
/>
<SegmentedControl
size="sm"
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
testID="cadence-interval-unit"
/>
</View>
<Text style={styles.preview}>{intervalPreview}</Text>
</View>
) : (
<View style={styles.section}>
<View style={styles.presetRow}>
{CRON_PRESETS.map((preset) => (
<CronPresetChip
key={preset.expression}
label={preset.label}
expression={preset.expression}
isSelected={trimmedCron === preset.expression}
onSelect={handlePresetPress}
/>
))}
</View>
<AdaptiveTextInput
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{cronFeedback}
<Text style={styles.hint}>Times are in UTC</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
function CronPresetChip({
label,
expression,
isSelected,
onSelect,
}: {
label: string;
expression: string;
isSelected: boolean;
onSelect: (expression: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(expression);
}, [onSelect, expression]);
const chipStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.chip,
isSelected && styles.chipSelected,
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
],
[isSelected],
);
const labelStyle = useMemo(
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
[isSelected],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
section: {
gap: theme.spacing[3],
},
intervalRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
intervalInput: {
width: 88,
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
modeControl: {
alignSelf: "flex-start",
},
presetRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
chip: {
minHeight: 32,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hint: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],
},
}));

View File

@@ -1,960 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Folder } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { getProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
buildScheduleProjectTargets,
PROJECT_OPTION_PREFIX,
type ScheduleProjectTarget,
} from "@/schedules/schedule-project-targets";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
import type { ProjectSummary } from "@/utils/projects";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
export interface ScheduleFormSheetProps {
serverId?: string;
visible: boolean;
onClose: () => void;
mode: "create" | "edit";
schedule?: ScheduleSummary;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
}
// The model/cwd config only exists on new-agent schedules; this screen filters
// to that target, but guard anyway so prefill stays type-safe.
function newAgentConfig(schedule: ScheduleSummary | undefined) {
if (schedule && schedule.target.type === "new-agent") {
return schedule.target.config;
}
return null;
}
function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialValues | undefined {
const config = newAgentConfig(schedule);
if (!config) {
return undefined;
}
return {
provider: config.provider as AgentProvider,
model: config.model ?? null,
modeId: config.modeId ?? null,
workingDir: config.cwd,
};
}
function buildProjectOptionTestId(optionId: string): string {
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
return `schedule-project-option-${targetKey}`;
}
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
const targets = buildScheduleProjectTargets(projects);
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
const options: ComboboxOption[] = targets.map((target) => ({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
}));
return { targets, options, targetByOptionId };
}
function resolveSelectedScheduleProjectTarget(input: {
targets: readonly ScheduleProjectTarget[];
serverId: string | null;
cwd: string;
}): ScheduleProjectTarget | null {
const cwd = input.cwd.trim();
if (!input.serverId || !cwd) {
return null;
}
return (
input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null
);
}
function isSelectedModelValidForProviders(input: {
providers: ProviderSelectorProvider[];
selectedProvider: AgentProvider | null;
selectedModel: string;
}): boolean {
if (!input.selectedProvider) {
return false;
}
const provider = input.providers.find((entry) => entry.id === input.selectedProvider);
if (!provider || provider.modelSelection.kind !== "models") {
return false;
}
const selectedModel = input.selectedModel.trim();
if (!selectedModel) {
return true;
}
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
function parseMaxRuns(raw: string): number | null {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function canSubmitScheduleForm(input: {
isAgentTarget: boolean;
isEdit: boolean;
promptTrimmed: string;
cadenceError: string | null;
isSubmitting: boolean;
selectedModelIsValid: boolean;
hasWorkingDir: boolean;
hasSelectedProject: boolean;
}): boolean {
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
return false;
}
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
// non-empty stored cwd; create requires a matched project.
if (input.isAgentTarget) {
return true;
}
if (!input.selectedModelIsValid) {
return false;
}
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
}
export function ScheduleFormSheet({
serverId,
visible,
onClose,
mode,
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
// (service.ts rejects newAgentConfig for them), so the form drops the
// project/model/mode pickers and shows the target agent read-only instead.
const isAgentTarget = isEdit && schedule?.target.type === "agent";
const { projects } = useProjects();
const { agents } = useAggregatedAgents({ includeArchived: true });
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const agentTargetLabel = useMemo(() => {
if (!schedule || schedule.target.type !== "agent") {
return null;
}
const { agentId } = schedule.target;
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
if (!agent) {
return "Agent unavailable";
}
return agent.title?.trim() || "Untitled agent";
}, [agents, schedule, serverId]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
);
const initialValues = useMemo(
() => (isEdit ? buildInitialValues(schedule) : undefined),
[isEdit, schedule],
);
// isCreateFlow drives useAgentFormState's RESOLVE pass that applies
// initialValues. We want that for edit too (to prefill the picker fields from
// the schedule's config), so this stays true in both modes: the form is
// always a "fill these fields" flow, seeded either from preferences (create)
// or from the schedule (edit).
const form = useAgentFormState({
initialServerId: serverId ?? null,
initialValues,
isVisible: visible,
isCreateFlow: true,
onlineServerIds,
});
const {
selectedServerId,
selectedProvider,
selectedModel,
selectedMode,
selectedThinkingOptionId,
workingDir,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerId,
setSelectedServerIdFromUser,
setWorkingDir,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
isAllModelsLoading,
persistFormPreferences,
} = form;
const selectedProjectTarget = useMemo(
() =>
resolveSelectedScheduleProjectTarget({
targets: projectOptions.targets,
serverId: selectedServerId,
cwd: workingDir,
}),
[projectOptions.targets, selectedServerId, workingDir],
);
const selectedProjectOptionId = selectedProjectTarget?.optionId ?? "";
const mutationServerId = selectedProjectTarget?.serverId ?? selectedServerId ?? serverId ?? "";
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
// Compare against the current server, not the matched target: an unmatched
// stored cwd has no target but still lives on a host, and switching hosts
// must still clear a provider/model that may not exist on the new one.
if (selectedServerId && selectedServerId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
setWorkingDirFromUser(target.cwd);
},
[
clearProviderSelectionFromUser,
selectedServerId,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
);
// One nested control selects provider -> model (the draft screen's selector).
// Render it as a full-width field that leads with the provider glyph and mutes
// its placeholder, matching the working-directory field.
const renderModelTrigger = useCallback(
({
selectedModelLabel,
disabled,
isOpen,
hovered,
pressed,
}: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}): ReactNode => (
<ModelTrigger
label={selectedModelLabel}
provider={selectedProvider}
disabled={disabled}
active={hovered || pressed || isOpen}
isPlaceholder={!selectedModel}
/>
),
[selectedModel, selectedProvider],
);
const { createSchedule, updateSchedule, isCreating, isUpdating } = useScheduleMutations({
serverId: mutationServerId,
});
const isSubmitting = isCreating || isUpdating;
// Name / prompt / cadence / maxRuns are local to this form, not part of
// useAgentFormState. Seed once per open from the schedule being edited.
const [name, setName] = useState(() => schedule?.name ?? "");
const [prompt, setPrompt] = useState(() => schedule?.prompt ?? "");
const [maxRuns, setMaxRuns] = useState(() =>
schedule?.maxRuns != null ? String(schedule.maxRuns) : "",
);
const [cadence, setCadence] = useState<ScheduleCadence>(
() => schedule?.cadence ?? DEFAULT_CADENCE,
);
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldResetKey, setFieldResetKey] = useState(0);
// The sheet stays mounted across opens, so the lazy initializers above only
// run once. Re-seed the locally-owned fields (name/prompt/cadence/maxRuns)
// each time the sheet transitions closed -> open; the picker fields are
// re-seeded by useAgentFormState from initialValues on the same flip.
const wasVisibleRef = useRef(false);
useEffect(() => {
if (visible && !wasVisibleRef.current) {
setName(schedule?.name ?? "");
setPrompt(schedule?.prompt ?? "");
setMaxRuns(schedule?.maxRuns != null ? String(schedule.maxRuns) : "");
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
// The sheet stays mounted, and the form reducer's reset-on-close only
// clears user-modified flags — not the picker values — so a create opened
// after an edit would inherit that schedule's server/cwd (including a
// stale ghost path). Clear them so create always starts fresh; provider
// and model re-resolve from preferences.
if (!isEdit) {
setSelectedServerId(null);
setWorkingDir("");
}
}
wasVisibleRef.current = visible;
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
const promptTrimmed = prompt.trim();
const trimmedWorkingDir = workingDir.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit = canSubmitScheduleForm({
isAgentTarget,
isEdit,
promptTrimmed,
cadenceError,
isSubmitting,
selectedModelIsValid,
hasWorkingDir: trimmedWorkingDir.length > 0,
hasSelectedProject: Boolean(selectedProjectTarget),
});
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
return false;
}
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
maxRuns: parseMaxRuns(maxRuns),
});
return true;
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
// New-agent target: submit the current working directory. On edit an untouched
// picker leaves this as the stored cwd, so it round-trips unchanged.
const submitNewAgent = useCallback(async (): Promise<boolean> => {
if (!selectedProvider || !trimmedWorkingDir) {
return false;
}
await persistFormPreferences();
const maxRunsValue = parseMaxRuns(maxRuns);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: trimmedWorkingDir,
},
maxRuns: maxRunsValue,
});
return true;
}
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: trimmedWorkingDir,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
return true;
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
trimmedWorkingDir,
updateSchedule,
]);
const handleSubmit = useCallback(async () => {
if (!promptTrimmed) {
return;
}
setSubmitError(null);
try {
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
if (submitted) {
onClose();
}
} catch (error) {
setSubmitError(toErrorMessage(error));
}
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
const header = useMemo<SheetHeader>(
() => ({ title: isEdit ? "Edit schedule" : "New schedule" }),
[isEdit],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
style={styles.footerButton}
variant="secondary"
onPress={onClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
style={styles.footerButton}
variant="default"
onPress={handleSubmitPress}
disabled={!canSubmit}
loading={isSubmitting}
testID="schedule-form-submit"
>
{isEdit ? "Save changes" : "Create schedule"}
</Button>
</View>
),
[canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
footer={footer}
webScrollbar
testID="schedule-form-sheet"
>
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<AdaptiveTextInput
testID="schedule-name-input"
accessibilityLabel="Schedule name"
initialValue={name}
resetKey={`schedule-name-${fieldResetKey}`}
value={name}
onChangeText={setName}
placeholder="Optional"
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Prompt</Text>
<AdaptiveTextInput
testID="schedule-prompt-input"
accessibilityLabel="Prompt"
initialValue={prompt}
resetKey={`schedule-prompt-${fieldResetKey}`}
value={prompt}
onChangeText={setPrompt}
placeholder="What should the agent do each run?"
style={styles.multilineInput}
multiline
numberOfLines={4}
textAlignVertical="top"
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
<CadenceEditor value={cadence} onChange={setCadence} error={cadenceError ?? undefined} />
</View>
<View style={styles.field}>
<Text style={styles.label}>Max runs</Text>
<AdaptiveTextInput
testID="schedule-max-runs-input"
accessibilityLabel="Max runs"
initialValue={maxRuns}
resetKey={`schedule-max-runs-${fieldResetKey}`}
value={maxRuns}
onChangeText={setMaxRuns}
placeholder="Unlimited"
style={styles.input}
keyboardType="number-pad"
/>
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
}
// ---------------------------------------------------------------------------
// Mode field - Combobox over the selected provider's modes.
// ---------------------------------------------------------------------------
function ModeField({
options,
selectedMode,
onSelect,
}: {
options: { id: string; label: string }[];
selectedMode: string;
onSelect: (modeId: string) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((option) => ({ id: option.id, label: option.label })),
[options],
);
const selectedLabel =
options.find((option) => option.id === selectedMode)?.label ?? "Default mode";
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
setOpen(false);
},
[onSelect],
);
const handlePress = useCallback(() => {
setOpen((current) => !current);
}, []);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.selectTrigger,
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
],
[open],
);
return (
<View style={styles.field}>
<Text style={styles.label}>Mode</Text>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handlePress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select mode (${selectedLabel})`}
testID="schedule-mode-trigger"
>
<Text style={styles.selectTriggerText} numberOfLines={1}>
{selectedLabel}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</Pressable>
</View>
<Combobox
options={comboboxOptions}
value={selectedMode}
onSelect={handleSelect}
searchable={comboboxOptions.length > 6}
title="Select mode"
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
/>
</View>
);
}
function ProjectField({
options,
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
/** Stored cwd for an edited schedule whose path matches no known project. */
fallbackCwd: string;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
const target = targetByOptionId.get(id);
if (!target) {
return;
}
onSelect(target);
setOpen(false);
},
[onSelect, targetByOptionId],
);
const handlePress = useCallback(() => {
setOpen((current) => !current);
}, []);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.selectTrigger,
(Boolean(hovered) || pressed || open) && styles.selectTriggerActive,
],
[open],
);
// Honest hydration: a stored cwd that matches no known project shows the
// shortened path itself (not the blank "Select project"), and stays put until
// the user deliberately picks a project.
const storedPath = fallbackCwd.trim();
const displayValue =
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
const isPlaceholder = !selectedTarget && !storedPath;
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<ProjectOptionItem option={option} selected={selected} active={active} onPress={onPress} />
),
[],
);
return (
<>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handlePress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select project (${displayValue})`}
testID="schedule-project-trigger"
>
<Text
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{displayValue}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</Pressable>
</View>
{description ? <Text style={styles.hint}>{description}</Text> : null}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
searchable
searchPlaceholder="Search projects..."
emptyText="No projects found"
title="Select project"
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
renderOption={renderOption}
/>
</>
);
}
function ProjectOptionItem({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement {
const leadingSlot = useMemo(
() => (
<View style={styles.optionIconBox}>
<Folder size={16} color={styles.chevron.color} />
</View>
),
[],
);
return (
<ComboboxItem
testID={buildProjectOptionTestId(option.id)}
label={option.label}
description={option.description}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
// ---------------------------------------------------------------------------
// Shared bits
// ---------------------------------------------------------------------------
/** Dynamic provider glyph - reads its color off a StyleSheet object so the
* runtime-resolved component stays compliant without useUnistyles. */
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider);
return <Icon size={16} color={styles.providerIcon.color} />;
}
// Non-interactive field rendered inside CombinedModelSelector's trigger (with
// triggerFill). The selector's outer Pressable owns press/hover; this leaf just
// paints the field and reads `active` for the focus border.
function ModelTrigger({
label,
provider,
disabled,
active,
isPlaceholder,
}: {
label: string;
provider: string | null;
disabled: boolean;
active: boolean;
isPlaceholder: boolean;
}): ReactElement {
const containerStyle = useMemo(
() => [
styles.selectTrigger,
active && styles.selectTriggerActive,
disabled && styles.selectTriggerDisabled,
],
[active, disabled],
);
return (
<View pointerEvents="none" style={containerStyle} testID="schedule-model-trigger">
<ProviderGlyph provider={provider} />
<Text
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{label}
</Text>
<ChevronDown size={16} color={styles.chevron.color} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
multilineInput: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
minHeight: 96,
},
hint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
readonlyField: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTriggerActive: {
borderColor: theme.colors.borderAccent,
},
selectTriggerDisabled: {
opacity: theme.opacity[50],
},
selectTriggerText: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
selectTriggerPlaceholder: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
optionIconBox: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
},
footer: {
flex: 1,
flexDirection: "row",
gap: theme.spacing[3],
},
footerButton: {
flex: 1,
},
// Static color holders read by the dynamic provider icon + chevron (compliant
// idiom - no useUnistyles in render).
providerIcon: {
color: theme.colors.foregroundMuted,
},
chevron: {
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -1,374 +0,0 @@
import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native";
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { getProviderIcon } from "@/components/provider-icons";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
// change (never call useUnistyles in render). See docs/unistyles.md.
const ThemedPencil = withUnistyles(Pencil);
const ThemedPause = withUnistyles(Pause);
const ThemedPlay = withUnistyles(Play);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedKebab = withUnistyles(MoreVertical);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const MENU_ICON_SIZE = 14;
const PROVIDER_ICON_SIZE = 16;
// Pending flags for each action so the parent table can wire a mutation hook
// and the row reflects in-flight state without owning the mutation itself.
export interface ScheduleRowPending {
pause?: boolean;
resume?: boolean;
runNow?: boolean;
delete?: boolean;
}
export interface ScheduleRowActions {
onEdit: () => void;
onPause: () => void;
onResume: () => void;
onRunNow: () => void;
onDelete: () => void;
}
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
/** Client-derived target line (agent title / project / shortened path). */
targetLabel: string;
/** Provider glyph, resolved from the schedule config or the target agent. */
provider: string | null;
/** Client-derived state — the single source for the badge and next-run copy. */
state: ScheduleDerivedState;
/** Host name, rendered when the list spans more than one host. */
serverName?: string;
/** True when only one host exists and the host name would be redundant. */
singleHost?: boolean;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function stateBadge(state: ScheduleDerivedState): {
label: string;
variant: "success" | "error" | "muted";
} {
switch (state) {
case "active":
return { label: "Active", variant: "success" };
case "paused":
return { label: "Paused", variant: "muted" };
case "expired":
return { label: "Expired", variant: "muted" };
case "finished":
return { label: "Finished", variant: "muted" };
case "targetGone":
return { label: "Target gone", variant: "error" };
}
}
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
if (next) {
parts.push(`Next run ${next}`);
}
}
if (serverName && !singleHost) {
parts.unshift(serverName);
}
return parts.join(" · ");
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider);
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
}
/**
* One schedule, rendered as a settings-style card row: provider glyph + title,
* a muted secondary line (model · cadence · next run), a StatusBadge, and the
* kebab menu that owns every row action. Tapping the row opens the editor.
*
* Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns
* press, the nested kebab Pressable never fights it, and the row background
* highlights without reflow.
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: ScheduleRowProps): ReactElement {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const title = resolveScheduleTitle(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
settingsStyles.row,
styles.row,
!isFirst && settingsStyles.rowBorder,
isHovered && !isCompact && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isHovered, isCompact],
);
return (
<View
style={styles.rowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
style={rowStyle}
onPress={onEdit}
accessibilityRole="button"
accessibilityLabel={`Edit schedule ${title}`}
testID={`schedule-row-${schedule.id}`}
>
<View style={styles.main}>
<View style={styles.leading}>
<ProviderGlyph provider={provider} />
</View>
<View style={styles.textGroup}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
</View>
</View>
<View style={styles.trailing}>
<StatusBadge label={badge.label} variant={badge.variant} />
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
onDelete={onDelete}
/>
</View>
</Pressable>
</View>
);
}
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
return (
<ThemedKebab
size={MENU_ICON_SIZE}
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
/>
);
}
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityRole={isNative ? "button" : undefined}
accessibilityLabel="Schedule actions"
testID={`schedule-kebab-${schedule.id}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
leading={editLeading}
onSelect={onEdit}
testID={`schedule-menu-edit-${schedule.id}`}
>
Edit schedule
</DropdownMenuItem>
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={deleteLeading}
destructive
status={pending?.delete ? "pending" : "idle"}
pendingLabel="Deleting..."
onSelect={onDelete}
testID={`schedule-menu-delete-${schedule.id}`}
>
Delete schedule
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabTriggerStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered];
}
const styles = StyleSheet.create((theme) => ({
// Static color holder for the dynamic provider icon (compliant idiom).
providerIcon: {
color: theme.colors.foregroundMuted,
},
rowContainer: {
position: "relative",
},
row: {
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
main: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
leading: {
width: PROVIDER_ICON_SIZE,
height: PROVIDER_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
textGroup: {
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
kebabTrigger: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
kebabTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -1,153 +0,0 @@
import { useCallback, useState, type ReactElement } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
schedule: AggregatedSchedule;
targetLabel: string;
provider: string | null;
state: ScheduleDerivedState;
serverName: string;
/** True when only one host exists, so the host name is redundant in rows. */
singleHost: boolean;
}
interface SchedulesTableProps {
rows: ScheduleRowView[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the screen's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: AggregatedSchedule) => void;
}
/**
* The schedules list: a single settings-style card of rows across every
* connected host, in a full-width list matching the History screen. Rows own
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
* a destructive confirm) and delegate editing upward.
*/
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{rows.map((row, index) => (
<SchedulesTableRow
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
isFirst={index === 0}
onEditSchedule={onEditSchedule}
/>
))}
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Per-row wrapper owns local in-flight state and binds mutations to this
// schedule's host. Local state keeps pending precise to the acting row even
// when several rows are acted on at once (the mutations hook exposes only a
// single global pending flag per action).
// ---------------------------------------------------------------------------
const NO_PENDING: ScheduleRowPending = {};
function SchedulesTableRow({
row,
isFirst,
onEditSchedule,
}: {
row: ScheduleRowView;
isFirst: boolean;
onEditSchedule: (schedule: AggregatedSchedule) => void;
}): ReactElement {
const { schedule } = row;
const { id, serverId } = schedule;
const mutations = useScheduleMutations({ serverId });
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
async (key: keyof ScheduleRowPending, action: () => Promise<void>): Promise<void> => {
setPending((current) => ({ ...current, [key]: true }));
try {
await action();
} catch {
// Mutations roll back their own optimistic cache writes on error and
// re-fetch on settle; surfacing per-row toasts here is out of scope.
} finally {
setPending((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
},
[],
);
const handleEdit = useCallback(() => {
onEditSchedule(schedule);
}, [onEditSchedule, schedule]);
const handlePause = useCallback(() => {
void runAction("pause", () => mutations.pauseSchedule(id));
}, [runAction, mutations, id]);
const handleResume = useCallback(() => {
void runAction("resume", () => mutations.resumeSchedule(id));
}, [runAction, mutations, id]);
const handleRunNow = useCallback(() => {
void runAction("runNow", () => mutations.runScheduleNow(id));
}, [runAction, mutations, id]);
const handleDelete = useCallback(() => {
void (async () => {
const confirmed = await confirmDialog({
title: "Delete schedule",
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
});
if (!confirmed) {
return;
}
await runAction("delete", () => mutations.deleteSchedule(id));
})();
}, [runAction, mutations, id, schedule]);
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}
onPause={handlePause}
onResume={handleResume}
onRunNow={handleRunNow}
onDelete={handleDelete}
/>
);
}
const styles = StyleSheet.create((theme) => ({
// Full-width list padding matching the History screen.
listContent: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
},
}));

View File

@@ -56,7 +56,6 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
@@ -1750,13 +1749,14 @@ function WorkspaceRowItem({
isDragging = false,
dragHandleProps,
}: WorkspaceRowItemProps) {
const currentPathname = usePathname();
const handlePress = useCallback(() => {
if (!workspace.serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return (
<WorkspaceRow
@@ -1882,7 +1882,6 @@ function ProjectBlock({
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -1904,16 +1903,14 @@ function ProjectBlock({
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, project],
);
const active = isProjectSelectedByRoute({
@@ -2066,7 +2063,7 @@ function ProjectBlock({
containerStyle={styles.workspaceListContainer}
/>
);
} else if (rowModel.trailingAction.kind === "new_workspace") {
} else if (rowModel.trailingAction.kind === "new_worktree") {
projectChildren = (
<NewWorkspaceGhostRow
project={project}
@@ -2089,7 +2086,7 @@ function ProjectBlock({
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
worktreeTarget={
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
@@ -2110,7 +2107,6 @@ function ProjectBlock({
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
// oxlint-disable-next-line complexity
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
@@ -2122,7 +2118,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
@@ -2189,8 +2184,6 @@ export function SidebarWorkspaceList({
}
return labels;
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
const content =
@@ -2216,7 +2209,6 @@ export function SidebarWorkspaceList({
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
@@ -2265,7 +2257,6 @@ function ProjectModeList({
pathname,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
@@ -2273,7 +2264,6 @@ function ProjectModeList({
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2462,7 +2452,6 @@ function ProjectModeList({
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
},
@@ -2473,7 +2462,6 @@ function ProjectModeList({
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { usePathname } from "expo-router";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -333,6 +334,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const currentPathname = usePathname();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
@@ -340,8 +342,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => {
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;

View File

@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},

View File

@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1.5],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {

View File

@@ -26,9 +26,6 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
}
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (workspaceAttachment) {
agentAttachments.push(workspaceAttachment);

View File

@@ -11,7 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
comment: attachment.attachment.comment ?? null,
});
}
if (isPullRequestContextAttachment(attachment)) {

View File

@@ -37,10 +37,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.32.0",
version: "0.31.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.31.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.115.0",
version: "2.114.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.115.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.17",
version: "0.2.12",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.17", "acp"],
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.13",
version: "0.4.12",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.13", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.164.0",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.3",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.24",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.36",
version: "1.0.33",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.36", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.5",
version: "0.19.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.5", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -335,15 +335,6 @@ const CATALOG_DATA = [
installLink: "https://stakpak.dev/",
command: ["stakpak", "acp"],
},
{
id: "traecli",
title: "TRAE CLI",
description: "ByteDance's official TRAE coding agent with native ACP support",
version: "manual",
iconId: "traecli",
installLink: "https://docs.trae.cn/cli",
command: ["traecli", "acp", "serve"],
},
{
id: "vtcode",
title: "VT Code",

View File

@@ -1,15 +1,5 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
{ type: "browser.automation.execute.request" }
>;
type BrowserAutomationExecuteResponse = Extract<
SessionInboundMessage,
{ type: "browser.automation.execute.response" }
>;
export type DesktopNotificationPermission = "granted" | "denied" | "default";
@@ -121,45 +111,15 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserPixelCapturePreparation {
token: string;
}
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
url: string;
}
export interface DesktopBrowserBridge {
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
setWorkspaceActiveBrowser?: (input: {
workspaceId: string;
browserId: string | null;
}) => Promise<void>;
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
executeAutomationCommand?: (
request: BrowserAutomationExecuteRequest,
) => Promise<BrowserAutomationExecuteResponse["payload"]>;
/** Capture a PNG screenshot of the guest viewport cropped to `rect`. */
captureElement?: (
browserId: string,
rect: { x: number; y: number; width: number; height: number },
) => Promise<string | null>;
/** Copy element text and/or an image to the system clipboard from main. */
copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>;
onPrepareForPixelCapture?: (
handler: (input: {
requestId: string;
browserId: string;
}) => Promise<DesktopBrowserPixelCapturePreparation>,
) => () => void;
onRestorePixelCapture?: (
handler: (input: DesktopBrowserPixelCapturePreparation) => Promise<void>,
) => () => void;
onCancelPixelCapture?: (
handler: (input: { requestId?: string; token?: string }) => Promise<void>,
) => () => void;
}
export interface DesktopInvokeBridge {

View File

@@ -43,7 +43,6 @@ describe("ACP provider catalog", () => {
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
expect(findProvider("traecli").command).toEqual(["traecli", "acp", "serve"]);
});
it("maps a catalog entry to the daemon provider config patch", () => {

View File

@@ -76,7 +76,6 @@ export interface UseAgentFormStateResult {
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
clearProviderSelectionFromUser: () => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
}
@@ -405,10 +404,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
],
);
const clearProviderSelectionFromUser = useCallback(() => {
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, []);
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
@@ -552,7 +547,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
}),
@@ -587,7 +581,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
],

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, type Href } from "expo-router";
import { router, usePathname, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -136,6 +136,7 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -222,9 +223,10 @@ export function useCommandCenter() {
navigateToAgent({
serverId: agent.serverId,
agentId: agent.id,
currentPathname: pathname,
});
},
[setOpen],
[pathname, setOpen],
);
const openProjectPicker = useOpenProjectPicker();

View File

@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace(action.serverId, action.workspaceId);
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();

View File

@@ -1,4 +1,4 @@
import { useMemo, useSyncExternalStore } from "react";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import type { ProjectSummary } from "@/utils/projects";
@@ -16,10 +16,6 @@ export type {
export const projectsQueryKey = ["projects"] as const;
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
return hosts.map((host) => host.serverId).join("|");
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
@@ -31,11 +27,6 @@ export interface UseProjectsResult {
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const hostInputs = useMemo<ProjectsHostInput[]>(
() =>
hosts.map((host) => ({
@@ -46,9 +37,8 @@ export function useProjects(): UseProjectsResult {
);
const projectsQuery = useQuery({
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
queryKey: projectsQueryKey,
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
staleTime: 5_000,
});
return {

View File

@@ -1,270 +0,0 @@
import { useCallback } from "react";
import {
useMutation,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
CreateScheduleOptions,
DaemonClient,
UpdateScheduleOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
export interface UseScheduleMutationsResult {
createSchedule: (input: CreateScheduleInput) => Promise<void>;
updateSchedule: (input: UpdateScheduleInput) => Promise<void>;
pauseSchedule: (id: string) => Promise<void>;
resumeSchedule: (id: string) => Promise<void>;
deleteSchedule: (id: string) => Promise<void>;
runScheduleNow: (id: string) => Promise<void>;
isCreating: boolean;
isUpdating: boolean;
isPausing: boolean;
isResuming: boolean;
isDeleting: boolean;
isRunningNow: boolean;
}
interface ScheduleListSnapshot {
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
}
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
if (!client) {
throw new Error(unavailableMessage);
}
return client;
}
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
return {
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
queryKey: schedulesQueryBaseKey,
}),
};
}
function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapshot): void {
for (const [queryKey, previous] of snapshot.previous) {
queryClient.setQueryData(queryKey, previous);
}
}
function updateSchedulesData(
queryClient: QueryClient,
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
(current) => {
if (!current) {
return current;
}
return { ...current, schedules: updateSchedules(current.schedules) };
},
);
}
function optimisticallySetStatus(
queryClient: QueryClient,
serverId: string,
id: string,
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateSchedulesData(queryClient, (schedules) =>
schedules.map((schedule) =>
schedule.serverId === serverId && schedule.id === id
? { ...schedule, status, pausedAt }
: schedule,
),
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateSchedulesData(queryClient, (schedules) =>
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
);
}
export function useScheduleMutations({
serverId,
}: {
serverId: string;
}): UseScheduleMutationsResult {
const queryClient = useQueryClient();
const { t } = useTranslation();
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}, [queryClient]);
const createMutation = useMutation({
mutationFn: async (input: CreateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleCreate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const updateMutation = useMutation({
mutationFn: async (input: UpdateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleUpdate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const pauseMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.schedulePause({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "paused");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const resumeMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleResume({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "active");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const deleteMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallyRemove(queryClient, serverId, id);
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const runNowMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleRunOnce({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const createSchedule = useCallback(
async (input: CreateScheduleInput): Promise<void> => {
await createMutation.mutateAsync(input);
},
[createMutation],
);
const updateSchedule = useCallback(
async (input: UpdateScheduleInput): Promise<void> => {
await updateMutation.mutateAsync(input);
},
[updateMutation],
);
const pauseSchedule = useCallback(
async (id: string): Promise<void> => {
await pauseMutation.mutateAsync(id);
},
[pauseMutation],
);
const resumeSchedule = useCallback(
async (id: string): Promise<void> => {
await resumeMutation.mutateAsync(id);
},
[resumeMutation],
);
const deleteSchedule = useCallback(
async (id: string): Promise<void> => {
await deleteMutation.mutateAsync(id);
},
[deleteMutation],
);
const runScheduleNow = useCallback(
async (id: string): Promise<void> => {
await runNowMutation.mutateAsync(id);
},
[runNowMutation],
);
return {
createSchedule,
updateSchedule,
pauseSchedule,
resumeSchedule,
deleteSchedule,
runScheduleNow,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isPausing: pauseMutation.isPending,
isResuming: resumeMutation.isPending,
isDeleting: deleteMutation.isPending,
isRunningNow: runNowMutation.isPending,
};
}

View File

@@ -1,65 +0,0 @@
import { useMemo, useSyncExternalStore } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
} from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
// Cache identity for the host set. The query also carries the runtime version
// (below) so it retries as connectivity changes and reliably fetches once a host
// comes online — even on a cold deep-link. The full-screen spinner flash that
// keying on the version used to cause is prevented by keepPreviousData plus the
// isInitialLoad(data === undefined) gate, not by dropping the version.
export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
}
export interface UseSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
isRefetching: boolean;
}
export function useSchedules(): UseSchedulesResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
});
return {
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
isError: query.isError,
error: query.error,
refetch: () => {
void query.refetch();
},
isRefetching: query.isRefetching,
};
}

View File

@@ -422,21 +422,7 @@ export const ar: TranslationResources = {
enterUrl: "أدخل URL",
openDevTools: "افتح أدوات تطوير المتصفح",
cancelSelector: "إلغاء محدد العنصر",
annotateElement: "التعليق على العنصر",
screenshotElement: "لقطة للعنصر",
screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة",
elementCopied: "تم نسخ العنصر إلى الحافظة",
screenshotFailed: "تعذّر نسخ لقطة الشاشة",
},
annotate: {
title: "التعليق على العنصر",
placeholder: "رسالة إلى الوكيل حول هذا العنصر…",
submit: "إرفاق",
cancel: "إلغاء",
},
devices: {
label: "حجم الجهاز",
responsive: "متجاوب",
selectElement: "حدد العنصر",
},
errors: {
failedToLoad: "فشل تحميل الصفحة",
@@ -784,7 +770,6 @@ export const ar: TranslationResources = {
},
sections: {
sessions: "السجل",
schedules: "الجداول",
},
worktreeSetup: {
title: "إعداد البرامج النصية لشجرة العمل",

View File

@@ -422,21 +422,7 @@ export const en = {
enterUrl: "Enter URL",
openDevTools: "Open browser dev tools",
cancelSelector: "Cancel element selector",
annotateElement: "Annotate element",
screenshotElement: "Screenshot element",
screenshotCopied: "Copied screenshot to clipboard",
elementCopied: "Copied element to clipboard",
screenshotFailed: "Couldn't copy screenshot",
},
annotate: {
title: "Annotate element",
placeholder: "Message to the agent about this element…",
submit: "Attach",
cancel: "Cancel",
},
devices: {
label: "Device size",
responsive: "Responsive",
selectElement: "Select element",
},
errors: {
failedToLoad: "Failed to load page",
@@ -791,7 +777,6 @@ export const en = {
},
sections: {
sessions: "History",
schedules: "Schedules",
},
worktreeSetup: {
title: "Set up worktree scripts",

View File

@@ -426,21 +426,7 @@ export const es: TranslationResources = {
enterUrl: "IngreseURL",
openDevTools: "Abrir herramientas de desarrollo del navegador",
cancelSelector: "Cancelar selector de elementos",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada al portapapeles",
elementCopied: "Elemento copiado al portapapeles",
screenshotFailed: "No se pudo copiar la captura",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensaje al agente sobre este elemento…",
submit: "Adjuntar",
cancel: "Cancelar",
},
devices: {
label: "Tamaño del dispositivo",
responsive: "Adaptable",
selectElement: "Seleccionar elemento",
},
errors: {
failedToLoad: "No se pudo cargar la página",
@@ -811,7 +797,6 @@ export const es: TranslationResources = {
},
sections: {
sessions: "Historial",
schedules: "Horarios",
},
worktreeSetup: {
title: "Configurar secuencias de comandos del árbol de trabajo",

View File

@@ -426,21 +426,7 @@ export const fr: TranslationResources = {
enterUrl: "EntrezURL",
openDevTools: "Outils de développement du navigateur ouvert",
cancelSelector: "Annuler le sélecteur d'élément",
annotateElement: "Annoter l'élément",
screenshotElement: "Capturer l'élément",
screenshotCopied: "Capture d'écran copiée dans le presse-papiers",
elementCopied: "Élément copié dans le presse-papiers",
screenshotFailed: "Impossible de copier la capture",
},
annotate: {
title: "Annoter l'élément",
placeholder: "Message à l'agent concernant cet élément…",
submit: "Joindre",
cancel: "Annuler",
},
devices: {
label: "Taille de l'appareil",
responsive: "Adaptatif",
selectElement: "Sélectionner un élément",
},
errors: {
failedToLoad: "Échec du chargement de la page",
@@ -810,7 +796,6 @@ export const fr: TranslationResources = {
},
sections: {
sessions: "Historique",
schedules: "Planifications",
},
worktreeSetup: {
title: "Configurer les scripts d'arbre de travail",

View File

@@ -426,21 +426,7 @@ export const ja: TranslationResources = {
enterUrl: "URLを入力",
openDevTools: "ブラウザ開発ツールを開く",
cancelSelector: "要素セレクターをキャンセル",
annotateElement: "要素に注釈を付ける",
screenshotElement: "要素のスクリーンショット",
screenshotCopied: "スクリーンショットをクリップボードにコピーしました",
elementCopied: "要素をクリップボードにコピーしました",
screenshotFailed: "スクリーンショットをコピーできませんでした",
},
annotate: {
title: "要素に注釈を付ける",
placeholder: "この要素についてエージェントへのメッセージ…",
submit: "添付",
cancel: "キャンセル",
},
devices: {
label: "デバイスサイズ",
responsive: "レスポンシブ",
selectElement: "要素を選択",
},
errors: {
failedToLoad: "ページの読み込みに失敗しました",
@@ -796,7 +782,6 @@ export const ja: TranslationResources = {
},
sections: {
sessions: "履歴",
schedules: "スケジュール",
},
worktreeSetup: {
title: "ワークツリースクリプトを設定",

View File

@@ -426,21 +426,7 @@ export const ptBR: TranslationResources = {
enterUrl: "Inserir URL",
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
cancelSelector: "Cancelar seletor de elemento",
annotateElement: "Anotar elemento",
screenshotElement: "Capturar elemento",
screenshotCopied: "Captura copiada para a área de transferência",
elementCopied: "Elemento copiado para a área de transferência",
screenshotFailed: "Não foi possível copiar a captura",
},
annotate: {
title: "Anotar elemento",
placeholder: "Mensagem ao agente sobre este elemento…",
submit: "Anexar",
cancel: "Cancelar",
},
devices: {
label: "Tamanho do dispositivo",
responsive: "Responsivo",
selectElement: "Selecionar elemento",
},
errors: {
failedToLoad: "Falha ao carregar página",
@@ -802,7 +788,6 @@ export const ptBR: TranslationResources = {
},
sections: {
sessions: "Histórico",
schedules: "Agendamentos",
},
worktreeSetup: {
title: "Configurar scripts de worktree",

View File

@@ -426,21 +426,7 @@ export const ru: TranslationResources = {
enterUrl: "Введите URL",
openDevTools: "Открыть инструменты разработки браузера",
cancelSelector: "Отменить выбор элемента",
annotateElement: "Аннотировать элемент",
screenshotElement: "Снимок элемента",
screenshotCopied: "Снимок скопирован в буфер обмена",
elementCopied: "Элемент скопирован в буфер обмена",
screenshotFailed: "Не удалось скопировать снимок",
},
annotate: {
title: "Аннотировать элемент",
placeholder: "Сообщение агенту об этом элементе…",
submit: "Прикрепить",
cancel: "Отмена",
},
devices: {
label: "Размер устройства",
responsive: "Адаптивный",
selectElement: "Выберите элемент",
},
errors: {
failedToLoad: "Не удалось загрузить страницу",
@@ -803,7 +789,6 @@ export const ru: TranslationResources = {
},
sections: {
sessions: "История",
schedules: "Расписания",
},
worktreeSetup: {
title: "Настройка сценариев рабочего дерева",

View File

@@ -422,21 +422,7 @@ export const zhCN: TranslationResources = {
enterUrl: "输入 URL",
openDevTools: "打开浏览器开发者工具",
cancelSelector: "取消元素选择器",
annotateElement: "标注元素",
screenshotElement: "截图元素",
screenshotCopied: "已将截图复制到剪贴板",
elementCopied: "已将元素复制到剪贴板",
screenshotFailed: "无法复制截图",
},
annotate: {
title: "标注元素",
placeholder: "给智能体关于此元素的留言…",
submit: "附加",
cancel: "取消",
},
devices: {
label: "设备尺寸",
responsive: "自适应",
selectElement: "选择元素",
},
errors: {
failedToLoad: "页面加载失败",
@@ -778,7 +764,6 @@ export const zhCN: TranslationResources = {
},
sections: {
sessions: "历史",
schedules: "计划",
},
worktreeSetup: {
title: "设置 worktree scripts",

View File

@@ -124,8 +124,8 @@ interface HelpSectionCase {
describe("keyboard-shortcuts", () => {
const matchingCases: MatchingShortcutCase[] = [
{
name: "matches Cmd+O to open project",
event: { key: "o", code: "KeyO", metaKey: true },
name: "matches Mod+Shift+O to create new agent",
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "agent.new",
},
@@ -227,8 +227,8 @@ describe("keyboard-shortcuts", () => {
action: "workspace.tab.close.current",
},
{
name: "matches Ctrl+O to open project on non-mac",
event: { key: "o", code: "KeyO", ctrlKey: true },
name: "matches Ctrl+Shift+O to create new agent on non-mac",
event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true },
context: { isMac: false },
action: "agent.new",
},
@@ -394,16 +394,6 @@ describe("keyboard-shortcuts", () => {
name: "does not keep old Alt+Shift+T binding",
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
},
{
name: "does not keep old Cmd+Shift+O open-project binding after rebind to Cmd+O",
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
context: { isMac: true },
},
{
name: "does not keep old Ctrl+Shift+O open-project binding after rebind to Ctrl+O",
event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true },
context: { isMac: false },
},
{
name: "does not match question-mark shortcut inside editable scopes",
event: { key: "?", code: "Slash", shiftKey: true },
@@ -590,7 +580,7 @@ describe("keyboard-shortcut help sections", () => {
name: "uses web defaults for workspace and tab jump",
context: { isMac: true, isDesktop: false },
expectedKeys: {
"new-agent": ["mod", "O"],
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
@@ -604,7 +594,7 @@ describe("keyboard-shortcut help sections", () => {
name: "uses desktop defaults for workspace and tab jump",
context: { isMac: true, isDesktop: true },
expectedKeys: {
"new-agent": ["mod", "O"],
"new-agent": ["mod", "shift", "O"],
"new-workspace": ["mod", "N"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],

View File

@@ -121,6 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"new-worktree": "settings.shortcuts.help.newWorktree",
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
@@ -165,33 +166,29 @@ const SHORTCUT_HELP_NOTE_KEYS: Record<string, string> = {
// --- Binding definitions ---
const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Open project ---
// Open project moved from Cmd+Shift+O to Cmd+O. The binding ids intentionally
// keep their original "cmd-shift-o" / "ctrl-shift-o" names: user shortcut
// overrides are keyed by binding id, so renaming them would silently drop a
// user's customized Open project shortcut on upgrade.
// --- New agent ---
{
id: "agent-new-cmd-shift-o-mac",
action: "agent.new",
combo: "Cmd+O",
combo: "Cmd+Shift+O",
when: { mac: true },
help: {
id: "new-agent",
section: "projects",
label: "Open project",
keys: ["mod", "O"],
keys: ["mod", "shift", "O"],
},
},
{
id: "agent-new-ctrl-shift-o-non-mac",
action: "agent.new",
combo: "Ctrl+O",
combo: "Ctrl+Shift+O",
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "projects",
label: "Open project",
keys: ["mod", "O"],
keys: ["mod", "shift", "O"],
},
},
@@ -221,6 +218,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- New worktree ---
{
id: "worktree-new-cmd-o-mac",
action: "worktree.new",
combo: "Cmd+O",
when: { mac: true, commandCenter: false },
help: {
id: "new-worktree",
section: "projects",
label: "New worktree",
keys: ["mod", "O"],
},
},
{
id: "worktree-new-ctrl-o-non-mac",
action: "worktree.new",
combo: "Ctrl+O",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "new-worktree",
section: "projects",
label: "New worktree",
keys: ["mod", "O"],
},
},
// --- Archive worktree ---
{
id: "worktree-archive-cmd-shift-backspace-mac",

View File

@@ -1,80 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NavigationContainerRefWithCurrent } from "@react-navigation/native";
import {
navigateToHostWorkspaceRoute,
registerWorkspaceRouteNavigationRef,
} from "./workspace-route-navigation";
function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) {
const dispatch = vi.fn();
const navigationRef = {
current: {
isReady: () => options.ready ?? true,
getRootState: () => rootState,
dispatch,
},
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>;
return { navigationRef, dispatch };
}
describe("navigateToHostWorkspaceRoute", () => {
beforeEach(() => {
vi.clearAllMocks();
registerWorkspaceRouteNavigationRef({
current: null,
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>)();
});
it("falls back to route navigation when no host route is mounted yet", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [{ key: "settings-general", name: "settings/[section]" }],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dispatch).not.toHaveBeenCalled();
expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("pops to the mounted host route and targets the requested workspace", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [
{
key: "host-server-1",
name: "h/[serverId]",
params: { serverId: "server-1" },
},
{ key: "settings-general", name: "settings/[section]" },
],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dismissTo).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
type: "POP_TO",
target: "root-stack",
payload: {
name: "h/[serverId]",
params: {
serverId: "server-1",
screen: "workspace/[workspaceId]/index",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
},
pop: true,
},
},
});
});
});

View File

@@ -8,17 +8,13 @@ import {
const ROOT_HOST_ROUTE_NAME = "h/[serverId]";
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
interface NavigateToHostWorkspaceRouteDeps {
dismissTo(route: string): void;
}
const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps = {
dismissTo: (route) => router.dismissTo(route as Href),
};
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
null;
interface NavigateToHostWorkspaceRouteOptions {
popToExistingHostRoute?: boolean;
}
export function registerWorkspaceRouteNavigationRef(
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
): () => void {
@@ -30,38 +26,33 @@ export function registerWorkspaceRouteNavigationRef(
};
}
function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null {
function findStackKeyWithRouteName(state: unknown, routeName: string): string | null {
if (!state || typeof state !== "object") {
return null;
}
const candidate = state as {
key?: unknown;
routeNames?: unknown;
routes?: unknown;
};
if (
typeof candidate.key === "string" &&
Array.isArray(candidate.routeNames) &&
candidate.routeNames.includes(routeName)
) {
return candidate.key;
}
if (!Array.isArray(candidate.routes)) {
return null;
}
if (
typeof candidate.key === "string" &&
candidate.routes.some(
(route) =>
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName,
)
) {
return candidate.key;
}
for (const route of candidate.routes) {
if (!route || typeof route !== "object") {
continue;
}
const childKey = findStackKeyWithMountedRouteName(
(route as { state?: unknown }).state,
routeName,
);
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName);
if (childKey) {
return childKey;
}
@@ -78,7 +69,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
}
const rootState = navigation.getRootState();
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME);
if (!target) {
return false;
}
@@ -108,11 +99,11 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
export function navigateToHostWorkspaceRoute(
route: string,
deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps,
options: NavigateToHostWorkspaceRouteOptions = {},
): void {
if (dispatchHostWorkspacePopTo(route)) {
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) {
return;
}
deps.dismissTo(route);
router.dismissTo(route as Href);
}

View File

@@ -90,7 +90,6 @@ export type AgentFormAction =
modelId: string;
availableModels: AgentModelDefinition[] | null;
}
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
| { type: "SET_WORKING_DIR"; value: string }
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
@@ -566,24 +565,6 @@ export function resolveAgentForm(
};
}
case "CLEAR_PROVIDER_SELECTION_FROM_USER":
return {
form: {
...state.form,
provider: null,
model: "",
modeId: "",
thinkingOptionId: "",
},
userModified: {
...state.userModified,
provider: true,
model: true,
modeId: true,
thinkingOptionId: true,
},
};
case "SET_THINKING_OPTION_FROM_USER":
return {
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },

View File

@@ -1,8 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true });
});
import type {
DaemonClient,
ConnectionState,
@@ -20,10 +16,6 @@ import {
type HostRuntimeStorage,
} from "./host-runtime";
vi.mock("@/browser-automation/handler", () => ({
mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined),
}));
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
@@ -350,18 +342,6 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
});
}
class BrowserClientLifecycle {
public active: Array<{ serverId: string; connectionId: string }> = [];
mount(input: { host: HostProfile; connection: HostConnection }): () => void {
const entry = { serverId: input.host.serverId, connectionId: input.connection.id };
this.active.push(entry);
return () => {
this.active = this.active.filter((current) => current !== entry);
};
}
}
describe("HostRuntimeController", () => {
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
const oldRelay: HostConnection = {
@@ -478,39 +458,6 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
it("keeps browser client lifecycle tied to the active host runtime client", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const fakeClient = makeConnectedProbeClient(12);
const lifecycle = new BrowserClientLifecycle();
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host: hostProfile, connection }) => ({
client: makeConnectedProbeClient(10) as unknown as DaemonClient,
serverId: hostProfile.serverId,
hostname: connection.id,
}),
getClientId: async () => "cid_runtime_stable",
mountClientHandlers: (input) => lifecycle.mount(input),
},
});
await controller.start({
autoProbe: false,
initialConnection: {
connectionId: "direct:lan:6767",
existingClient: fakeClient as unknown as DaemonClient,
},
});
expect(lifecycle.active).toEqual([{ serverId: "srv_test", connectionId: "direct:lan:6767" }]);
await controller.stop();
expect(lifecycle.active).toEqual([]);
});
it("adopts the first successful probe on startup", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = [];
@@ -1484,7 +1431,7 @@ describe("HostRuntimeStore", () => {
entries: [
makeFetchAgentsEntry({
id: "agent-recent",
cwd: "/workspaces/paseo",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-04T12:00:00.000Z",
title: "Recent agent",
}),
@@ -1497,7 +1444,7 @@ describe("HostRuntimeStore", () => {
entries: [
makeFetchAgentsEntry({
id: "agent-stale-attention",
cwd: "/workspaces/paseo-pr67-review",
cwd: "/Users/moboudra/dev/paseo-pr67-review",
updatedAt: "2026-02-20T08:00:00.000Z",
title: "Needs triage",
requiresAttention: true,
@@ -1665,7 +1612,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/workspaces/paseo",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:29:00.000Z",
archivedAt: null,
title: "Stale active copy",

View File

@@ -37,8 +37,6 @@ import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -47,7 +45,6 @@ import {
} from "@/workspace/legacy-daemon-workspaces";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/query/query-client";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -141,11 +138,6 @@ export interface HostRuntimeControllerDeps {
}>;
getClientId: () => Promise<string>;
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
mountClientHandlers?: (input: {
client: DaemonClient;
host: HostProfile;
connection: HostConnection;
}) => () => void;
}
export interface HostRuntimeStorage {
@@ -522,12 +514,6 @@ function probeIntervalForConnection(
}
function createDefaultDeps(): HostRuntimeControllerDeps {
const desktopBrowserAutomationAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
: undefined;
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
@@ -537,7 +523,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
@@ -575,15 +560,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
connectToDaemon(connection, {
...(host.serverId ? { serverId: host.serverId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
if (!browserAutomationCapabilities) {
return () => {};
}
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
},
};
}
@@ -596,7 +574,6 @@ export class HostRuntimeController {
private listeners = new Set<() => void>();
private activeClient: DaemonClient | null = null;
private unsubscribeClientStatus: (() => void) | null = null;
private unsubscribeClientHandlers: (() => void) | null = null;
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
private started = false;
private connectionFirstSeenAt = new Map<string, number>();
@@ -679,10 +656,6 @@ export class HostRuntimeController {
this.unsubscribeClientStatus();
this.unsubscribeClientStatus = null;
}
if (this.unsubscribeClientHandlers) {
this.unsubscribeClientHandlers();
this.unsubscribeClientHandlers = null;
}
if (this.activeClient) {
const prev = this.activeClient;
this.activeClient = null;
@@ -1160,10 +1133,6 @@ export class HostRuntimeController {
this.unsubscribeClientStatus();
this.unsubscribeClientStatus = null;
}
if (this.unsubscribeClientHandlers) {
this.unsubscribeClientHandlers();
this.unsubscribeClientHandlers = null;
}
if (this.activeClient) {
const previousClient = this.activeClient;
this.activeClient = null;
@@ -1237,8 +1206,6 @@ export class HostRuntimeController {
}
this.activeClient = client;
this.unsubscribeClientHandlers =
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
this.applyConnectionEvent({
type: "select_connection",
connectionId: connection.id,

View File

@@ -1,93 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { toErrorMessage } from "@/utils/error-messages";
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
serverId: string;
serverName: string;
}
export interface ScheduleRuntimeSnapshot {
connectionStatus: string;
}
export interface ScheduleRuntime {
getClient(serverId: string): Pick<DaemonClient, "scheduleList"> | null;
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
}
/** A schedule tagged with the host it came from, so the flat list can render a
* per-row host label and scope mutations without host sections. */
export interface AggregatedSchedule extends ScheduleSummary {
serverId: string;
serverName: string;
}
export interface ScheduleHostError {
serverId: string;
serverName: string;
message: string;
}
export interface FetchAggregatedSchedulesResult {
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
}
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
runtime: ScheduleRuntime;
}
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
* every connected host fails do we throw so the screen shows a full error.
*/
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
await Promise.all(
input.hosts.map(async (host) => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return;
}
connectedAttempts += 1;
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
for (const schedule of payload.schedules) {
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
}
} catch (error) {
hostErrors.push({
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
});
}
}),
);
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
}

View File

@@ -1,133 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
return {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
...overrides,
};
}
function resolve(
schedule: ScheduleSummary,
options?: {
agents?: Array<[string, ScheduleTargetAgent]>;
projects?: Array<[string, string]>;
agentDataLoaded?: boolean;
},
) {
return resolveSchedule({
schedule,
serverId: "host-1",
now: NOW,
agentsByKey: new Map(options?.agents ?? []),
projectNameByCwd: new Map(options?.projects ?? []),
agentDataLoaded: options?.agentDataLoaded ?? true,
});
}
describe("resolveSchedule state", () => {
it("keeps active and paused schedules runnable", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
expect(scheduleBucket("active")).toBe("runnable");
expect(scheduleBucket("paused")).toBe("runnable");
});
it("treats a past expiresAt as expired regardless of status", () => {
const result = resolve(
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
);
expect(result.state).toBe("expired");
expect(result.bucket).toBe("ended");
});
it("ignores an unparseable expiresAt", () => {
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
});
it("derives finished only from completed-and-not-expired", () => {
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
expect(
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
).toBe("expired");
});
it("marks an agent target gone when the client has no such agent", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).state).toBe("targetGone");
expect(resolve(schedule).bucket).toBe("ended");
});
it("does not claim gone before the agent directory has loaded", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
});
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
const paused = makeSchedule({
status: "paused",
target: { type: "agent", agentId: AGENT_ID },
});
expect(resolve(paused).state).toBe("targetGone");
});
it("never claims a new-agent cwd is gone", () => {
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
});
});
describe("resolveSchedule target line", () => {
it("names an agent target by its client title and provider", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
});
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
expect(result.state).toBe("active");
});
it("falls back to Untitled agent when the agent has no title", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
const result = resolve(schedule, {
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
});
expect(result.target.label).toBe("Untitled agent");
});
it("labels a gone agent target as unavailable with no glyph", () => {
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
});
it("names a new-agent cwd by matched project, else the shortened path", () => {
const matched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
});
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
label: "My Project",
provider: "codex",
});
const unmatched = makeSchedule({
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
});
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
});
});

View File

@@ -1,109 +0,0 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
// Derived from existing fields only — no new protocol state. "active"/"paused"
// mirror the stored status; the rest are computed truths the daemon does not
// spell out in a single field.
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
export type ScheduleBucket = "runnable" | "ended";
export interface ScheduleTargetAgent {
title: string | null;
provider: string | null;
}
export interface ScheduleTargetResolution {
/** The target line: agent title, project name, or the shortened cwd. */
label: string;
/** Provider glyph for the row, when known. */
provider: string | null;
}
export interface ResolvedSchedule {
state: ScheduleDerivedState;
bucket: ScheduleBucket;
target: ScheduleTargetResolution;
}
export interface ResolveScheduleInput {
schedule: ScheduleSummary;
serverId: string;
now: number;
/** Client agent directory keyed by `${serverId}:${agentId}`. */
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
/** Known project roots keyed by `${serverId}:${cwd}`. */
projectNameByCwd: ReadonlyMap<string, string>;
/**
* Whether the agent directory has finished its first load. While false we do
* not claim an agent target is gone — absence would just be a cold cache.
*/
agentDataLoaded: boolean;
}
function agentKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
function isExpired(schedule: ScheduleSummary, now: number): boolean {
if (!schedule.expiresAt) {
return false;
}
const expiresAt = Date.parse(schedule.expiresAt);
return Number.isFinite(expiresAt) && expiresAt <= now;
}
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
if (schedule.target.type !== "agent" || !agentDataLoaded) {
return false;
}
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
}
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
if (schedule.target.type === "agent") {
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
if (agent) {
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
}
return { label: "Agent unavailable", provider: null };
}
return {
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
provider: schedule.target.config.provider,
};
}
// One badge, one truth. Order matters: expiry and a missing target are more
// informative than the raw "completed"/"paused" status, so they win.
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
const { schedule, now } = input;
if (isExpired(schedule, now)) {
return "expired";
}
if (isAgentTargetGone(input)) {
return "targetGone";
}
if (schedule.status === "completed") {
return "finished";
}
if (schedule.status === "paused") {
return "paused";
}
return "active";
}
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
return state === "active" || state === "paused" ? "runnable" : "ended";
}
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
const state = deriveState(input);
return {
state,
bucket: scheduleBucket(state),
target: resolveTarget(input),
};
}

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProjectSummary } from "@/utils/projects";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
describeScheduleCwd,
} from "./schedule-project-targets";
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
projectKey: "proj",
projectName: "Project",
hosts: [],
totalWorkspaceCount: 0,
hostCount: 0,
onlineHostCount: 0,
...overrides,
};
}
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
workspaceCount: 0,
workspaces: [],
...overrides,
};
}
describe("buildScheduleProjectTargets", () => {
it("emits one target per online host with a repo root", () => {
const targets = buildScheduleProjectTargets([
makeProject({
projectName: "Alpha",
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
}),
]);
expect(targets).toHaveLength(2);
expect(targets[0]).toMatchObject({
serverId: "host-1",
cwd: "/tmp/alpha",
projectName: "Alpha",
});
});
it("skips offline hosts and blank repo roots", () => {
const targets = buildScheduleProjectTargets([
makeProject({
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
}),
]);
expect(targets).toHaveLength(0);
});
});
describe("describeScheduleCwd", () => {
it("prefers a matched project name and shortens unmatched paths", () => {
const byCwd = buildProjectNameByCwd(
buildScheduleProjectTargets([
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
]),
);
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
).toBe("Alpha");
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
).toBe("~/api");
});
});

View File

@@ -1,75 +0,0 @@
import type { ProjectSummary } from "@/utils/projects";
import { shortenPath } from "@/utils/shorten-path";
export const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
export function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
/**
* The project roots the schedule form can target: one per online host of each
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
*/
export function buildScheduleProjectTargets(
projects: readonly ProjectSummary[],
): ScheduleProjectTarget[] {
const targets: ScheduleProjectTarget[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
targets.push({
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
});
}
}
return targets;
}
function projectNameKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd.trim()}`;
}
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
export function buildProjectNameByCwd(
targets: readonly ScheduleProjectTarget[],
): Map<string, string> {
const byCwd = new Map<string, string>();
for (const target of targets) {
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
}
return byCwd;
}
/**
* Name a stored cwd for display: the matching project name when the client
* knows this root on this host, otherwise the shortened path itself. Never
* blank, never a claim the client cannot back up.
*/
export function describeScheduleCwd(input: {
serverId: string;
cwd: string;
projectNameByCwd: ReadonlyMap<string, string>;
}): string {
return (
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
);
}

View File

@@ -56,9 +56,12 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import {
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveSelectedHostProject,
useHostProjects,
type HostProjectListItem,
} from "@/projects/host-projects";
@@ -85,7 +88,6 @@ import {
resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId,
} from "./new-workspace-initial-context";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
@@ -166,6 +168,7 @@ interface PickerSelection {
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_OPTION_PREFIX = "project:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
// this so toggling Isolation to Local hides the row without shifting the form.
@@ -507,6 +510,20 @@ function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function NewWorkspacePickerOption({
option,
selected,
@@ -1147,6 +1164,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
currentPathname: "/new",
target: submission.target,
});
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
@@ -1269,6 +1287,7 @@ interface NewWorkspaceInitialContextState {
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
routeDisplayName: string;
}
function useNewWorkspaceInitialContext({
@@ -1335,6 +1354,112 @@ function useNewWorkspaceInitialContext({
projects,
routeProject,
lastActiveProject,
routeDisplayName,
};
}
interface NewWorkspaceProjectPickerInput {
selectedServerId: string;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
displayName?: string;
allowAllProjects: boolean;
}
interface NewWorkspaceProjectPickerState {
projects: HostProjectListItem[];
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
selectedDisplayName: string;
projectPickerOptions: Array<{ id: string; label: string }>;
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
function useNewWorkspaceProjectPicker({
selectedServerId,
projects,
routeProject,
lastActiveProject,
displayName: displayNameProp,
allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
const displayName = displayNameProp?.trim() ?? "";
const selectableProjects = useMemo(
() =>
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
[allowAllProjects, projects, selectedServerId],
);
const initialProject = useMemo(
() =>
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject,
projects: selectableProjects,
serverId: selectedServerId,
allowAllProjects,
}),
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
);
const routeProjectKey = routeProject?.projectKey ?? null;
useEffect(() => {
setManualProjectKey(null);
}, [routeProjectKey]);
const selectedProjectKey = useMemo(() => {
if (manualProjectKey) {
const manual = resolveSelectedHostProject({
selectedProjectKey: manualProjectKey,
projects: selectableProjects,
routeProject: null,
lastActiveProject: null,
});
if (manual) return manual.projectKey;
}
return initialProject?.projectKey ?? null;
}, [initialProject, manualProjectKey, selectableProjects]);
const selectedProject = useMemo(
() =>
resolveSelectedHostProject({
selectedProjectKey,
projects: selectableProjects,
routeProject,
lastActiveProject,
}),
[lastActiveProject, routeProject, selectableProjects, selectedProjectKey],
);
const { options: projectPickerOptions, projectByOptionId } = useMemo(
() => computeProjectOptionData(selectableProjects),
[selectableProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project) return;
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
setManualProjectKey(project.projectKey);
},
[allowAllProjects, projectByOptionId],
);
return {
projects,
selectedProject,
selectedSourceDirectory: selectedProject
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
: null,
selectedDisplayName: selectedProject?.projectName ?? displayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}
@@ -1573,6 +1698,7 @@ export function NewWorkspaceScreen({
projects,
routeProject,
lastActiveProject,
routeDisplayName,
} = useNewWorkspaceInitialContext({
serverId,
sourceDirectory: sourceDirectoryProp,
@@ -1621,6 +1747,7 @@ export function NewWorkspaceScreen({
projects,
routeProject,
lastActiveProject,
displayName: routeDisplayName,
allowAllProjects: supportsWorkspaceMultiplicity,
});
@@ -1993,7 +2120,7 @@ export function NewWorkspaceScreen({
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) =>
navigateToWorkspace(targetServerId, workspaceId),
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }),
});
return;
}

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { isWorkspaceArchivePending } from "@/contexts/session-workspace-upserts";
import {
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
resolveInitialWorkspaceProject,
type HostProjectListItem,
} from "@/projects/host-projects";
import {
createManualProjectSelectionContextKey,
createProjectSelectionContextKey,
createProjectSelection,
reconcileProjectSelection,
resolveInitialProjectSelectionSource,
resolveProjectSelection,
type ProjectSelection,
type ProjectSelectionContext,
} from "./project-selection";
const PROJECT_OPTION_PREFIX = "project:";
interface NewWorkspaceProjectPickerInput {
selectedServerId: string;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
allowAllProjects: boolean;
}
interface NewWorkspaceProjectPickerState {
selectedProject: HostProjectListItem | null;
selectedSourceDirectory: string | null;
projectPickerOptions: ComboboxOptionType[];
projectByOptionId: Map<string, HostProjectListItem>;
selectedProjectOptionId: string;
projectTriggerLabel: string;
handleSelectProjectOption: (id: string) => void;
}
function projectOptionId(projectId: string): string {
return `${PROJECT_OPTION_PREFIX}${projectId}`;
}
function computeProjectOptionData(projects: readonly HostProjectListItem[]) {
const projectByOptionId = new Map<string, HostProjectListItem>();
const options = projects.map((project) => {
const id = projectOptionId(project.projectKey);
projectByOptionId.set(id, project);
return { id, label: project.projectName };
});
return { options, projectByOptionId };
}
function resolveWorkspaceIdFromProjectWorkspaceKey(input: {
selectedServerId: string;
workspaceKey: string;
}): string | null {
const prefix = `${input.selectedServerId}:`;
return input.workspaceKey.startsWith(prefix) ? input.workspaceKey.slice(prefix.length) : null;
}
function hasPendingArchiveForProject(input: {
selectedServerId: string;
project: HostProjectListItem;
}): boolean {
for (const workspaceKey of input.project.workspaceKeys) {
const workspaceId = resolveWorkspaceIdFromProjectWorkspaceKey({
selectedServerId: input.selectedServerId,
workspaceKey,
});
if (
workspaceId &&
isWorkspaceArchivePending({ serverId: input.selectedServerId, workspaceId })
) {
return true;
}
}
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
}
export function useNewWorkspaceProjectPicker({
selectedServerId,
projects,
routeProject,
lastActiveProject,
allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const selectableProjects = useMemo(
() =>
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
[allowAllProjects, projects, selectedServerId],
);
const initialProject = useMemo(
() =>
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject,
projects: selectableProjects,
serverId: selectedServerId,
allowAllProjects,
}),
[allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId],
);
const routeProjectKey = routeProject?.projectKey ?? null;
const selectionContextKey = createProjectSelectionContextKey({
selectedServerId,
routeProjectKey,
allowAllProjects,
});
const manualSelectionContextKey = createManualProjectSelectionContextKey({
selectedServerId,
routeProjectKey,
});
const shouldPreserveMissingProject = useCallback(
(project: HostProjectListItem) =>
hasPendingArchiveForProject({
selectedServerId,
project,
}),
[selectedServerId],
);
const selectionContext = useMemo<ProjectSelectionContext>(
() => ({
contextKey: selectionContextKey,
manualContextKey: manualSelectionContextKey,
initialProject,
initialProjectSource: resolveInitialProjectSelectionSource({
initialProject,
routeProject,
lastActiveProject,
}),
projects: selectableProjects,
routeProject,
lastActiveProject,
shouldPreserveMissingProject,
}),
[
initialProject,
lastActiveProject,
manualSelectionContextKey,
routeProject,
selectableProjects,
selectionContextKey,
shouldPreserveMissingProject,
],
);
const [projectSelection, setProjectSelection] = useState<ProjectSelection>(() =>
createProjectSelection(selectionContext),
);
useEffect(() => {
setProjectSelection((current) => reconcileProjectSelection(current, selectionContext));
}, [selectionContext]);
const activeSelection = reconcileProjectSelection(projectSelection, selectionContext);
const selectedProject = resolveProjectSelection(activeSelection, selectionContext);
const { options: projectPickerOptions, projectByOptionId } = useMemo(
() => computeProjectOptionData(selectableProjects),
[selectableProjects],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
const project = projectByOptionId.get(id);
if (!project) return;
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
setProjectSelection({
contextKey: manualSelectionContextKey,
projectKey: project.projectKey,
project,
source: "manual",
});
},
[allowAllProjects, manualSelectionContextKey, projectByOptionId],
);
return {
selectedProject,
selectedSourceDirectory: selectedProject
? getHostProjectSourceDirectory(selectedProject, selectedServerId)
: null,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "",
projectTriggerLabel: selectedProject?.projectName ?? "Choose project",
handleSelectProjectOption,
};
}

View File

@@ -1,309 +0,0 @@
import { describe, expect, it } from "vitest";
import type { HostProjectListItem } from "@/projects/host-projects";
import {
createManualProjectSelectionContextKey,
createProjectSelectionContextKey,
createProjectSelection,
reconcileProjectSelection,
resolveInitialProjectSelectionSource,
resolveProjectSelection,
type ProjectSelection,
type ProjectSelectionContext,
} from "./project-selection";
function project(projectKey: string, serverId = "host"): HostProjectListItem {
return {
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/work/${projectKey}`,
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
workspaceKeys: [],
};
}
function context(
input: Partial<ProjectSelectionContext> & {
initialProject: HostProjectListItem | null;
projects: HostProjectListItem[];
},
): ProjectSelectionContext {
const contextKey = input.contextKey ?? "host:";
const routeProject = input.routeProject ?? null;
const lastActiveProject = input.lastActiveProject ?? null;
return {
contextKey,
manualContextKey: input.manualContextKey ?? contextKey,
routeProject,
lastActiveProject,
initialProjectSource:
input.initialProjectSource ??
resolveInitialProjectSelectionSource({
initialProject: input.initialProject,
routeProject,
lastActiveProject,
}),
shouldPreserveMissingProject: () => false,
...input,
};
}
describe("reconcileProjectSelection", () => {
it("keeps a still-selectable project when the default moves after archive", () => {
const remembered = project("remembered");
const other = project("other");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered, other] }),
);
const afterArchive = context({
initialProject: other,
projects: [other, remembered],
});
const reconciled = reconcileProjectSelection(current, afterArchive);
expect(reconciled).toEqual({
contextKey: "host:",
projectKey: remembered.projectKey,
project: remembered,
source: "initial",
});
expect(resolveProjectSelection(reconciled, afterArchive)).toEqual(remembered);
});
it("resets stale selection when the route project context changes", () => {
const manual = project("manual");
const routeProject = project("route-project");
const current: ProjectSelection = {
contextKey: "host:previous-route",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const nextContext = context({
contextKey: "host:route-project",
initialProject: routeProject,
projects: [manual, routeProject],
routeProject,
});
expect(reconcileProjectSelection(current, nextContext)).toEqual({
contextKey: "host:route-project",
projectKey: routeProject.projectKey,
project: routeProject,
source: "initial",
});
});
it("hydrates an empty initial selection when projects arrive", () => {
const initialProject = project("hydrated");
const current = createProjectSelection(context({ initialProject: null, projects: [] }));
const hydratedContext = context({
initialProject,
projects: [initialProject],
});
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:",
projectKey: initialProject.projectKey,
project: initialProject,
source: "initial",
});
});
it("stores hydrated project snapshots before archive gaps", () => {
const routeProject = project("route-project");
const hydratedProject: HostProjectListItem = {
...routeProject,
workspaceKeys: ["host:workspace"],
};
const current = createProjectSelection(
context({ initialProject: routeProject, projects: [], routeProject }),
);
const afterHydration = context({
initialProject: hydratedProject,
projects: [hydratedProject],
routeProject,
});
const hydratedSelection = reconcileProjectSelection(current, afterHydration);
expect(hydratedSelection).toEqual({
contextKey: "host:",
projectKey: hydratedProject.projectKey,
project: hydratedProject,
source: "initial",
});
const archiveGap = context({
initialProject: routeProject,
projects: [],
routeProject,
shouldPreserveMissingProject: (candidate) =>
candidate.workspaceKeys.includes("host:workspace"),
});
expect(resolveProjectSelection(hydratedSelection, archiveGap)).toEqual(hydratedProject);
});
it("resets an automatic fallback when the remembered project hydrates", () => {
const fallback = project("fallback");
const remembered = project("remembered");
const current = createProjectSelection(
context({ initialProject: fallback, projects: [fallback, remembered] }),
);
const afterRememberedHydration = context({
initialProject: remembered,
projects: [fallback, remembered],
lastActiveProject: remembered,
});
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({
contextKey: "host:",
projectKey: remembered.projectKey,
project: remembered,
source: "initial",
});
});
it("keeps manual selections when the remembered project hydrates", () => {
const manual = project("manual");
const remembered = project("remembered");
const current: ProjectSelection = {
contextKey: "host:",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const afterRememberedHydration = context({
initialProject: remembered,
projects: [manual, remembered],
lastActiveProject: remembered,
});
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
});
it("resets fallback selection when host project capability changes", () => {
const fallback = project("git-fallback");
const remembered = project("remembered-directory");
const current = createProjectSelection(
context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
allowAllProjects: false,
}),
initialProject: fallback,
projects: [fallback, remembered],
}),
);
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
allowAllProjects: true,
}),
initialProject: remembered,
projects: [fallback, remembered],
});
expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({
contextKey: "host:all-projects:",
projectKey: remembered.projectKey,
project: remembered,
source: "initial",
});
});
it("keeps a still-selectable manual selection when host project capability changes", () => {
const fallback = project("git-fallback");
const manual = project("manual-choice");
const remembered = project("remembered-directory");
const current: ProjectSelection = {
contextKey: createManualProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
}),
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const afterCapabilityHydration = context({
contextKey: createProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
allowAllProjects: true,
}),
manualContextKey: createManualProjectSelectionContextKey({
selectedServerId: "host",
routeProjectKey: null,
}),
initialProject: remembered,
projects: [fallback, manual, remembered],
});
const reconciled = reconcileProjectSelection(current, afterCapabilityHydration);
expect(reconciled).toEqual(current);
expect(resolveProjectSelection(reconciled, afterCapabilityHydration)).toEqual(manual);
});
it("keeps the selected project snapshot during a pending archive gap", () => {
const remembered = project("remembered");
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered] }),
);
const withoutRemembered = context({
initialProject: fallback,
projects: [fallback],
shouldPreserveMissingProject: (candidate) => candidate.projectKey === remembered.projectKey,
});
const reconciled = reconcileProjectSelection(current, withoutRemembered);
expect(reconciled).toEqual(current);
expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered);
});
it("falls back when the selected project disappears without a pending archive", () => {
const remembered = project("remembered");
const fallback = project("fallback");
const current = createProjectSelection(
context({ initialProject: remembered, projects: [remembered] }),
);
const withoutRemembered = context({
initialProject: fallback,
projects: [fallback],
});
expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({
contextKey: "host:",
projectKey: fallback.projectKey,
project: fallback,
source: "initial",
});
});
it("resolves manual selections from selectable projects, not route or remembered projects", () => {
const manual = project("manual");
const routeProject = project("route-project");
const remembered = project("remembered");
const current: ProjectSelection = {
contextKey: "host:route-project",
projectKey: manual.projectKey,
project: manual,
source: "manual",
};
const selectionContext = context({
contextKey: "host:route-project",
initialProject: routeProject,
projects: [manual],
routeProject,
lastActiveProject: remembered,
});
expect(resolveProjectSelection(current, selectionContext)).toEqual(manual);
});
});

View File

@@ -1,162 +0,0 @@
import type { HostProjectListItem } from "@/projects/host-projects";
export type ProjectSelectionSource = "initial" | "manual";
export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null;
export interface ProjectSelection {
contextKey: string;
projectKey: string | null;
project: HostProjectListItem | null;
source: ProjectSelectionSource;
}
export interface ProjectSelectionContext {
contextKey: string;
manualContextKey: string;
initialProject: HostProjectListItem | null;
initialProjectSource: InitialProjectSelectionSource;
projects: HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
shouldPreserveMissingProject: (project: HostProjectListItem) => boolean;
}
export function createProjectSelectionContextKey(input: {
selectedServerId: string;
routeProjectKey: string | null;
allowAllProjects: boolean;
}): string {
const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects";
return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`;
}
export function createManualProjectSelectionContextKey(input: {
selectedServerId: string;
routeProjectKey: string | null;
}): string {
return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`;
}
export function createProjectSelection({
contextKey,
initialProject,
}: ProjectSelectionContext): ProjectSelection {
return {
contextKey,
projectKey: initialProject?.projectKey ?? null,
project: initialProject,
source: "initial",
};
}
export function resolveInitialProjectSelectionSource(input: {
initialProject: HostProjectListItem | null;
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): InitialProjectSelectionSource {
if (!input.initialProject) {
return null;
}
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
return "route";
}
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
return "lastActive";
}
return "fallback";
}
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
const projectKey = selection.projectKey?.trim() ?? "";
return projectKey || null;
}
function resolveSelectedProjectFromInitialInputs(
projectKey: string,
context: ProjectSelectionContext,
): HostProjectListItem | null {
return (
(context.routeProject?.projectKey === projectKey ? context.routeProject : null) ??
(context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null)
);
}
function refreshSelectionProject(
selection: ProjectSelection,
project: HostProjectListItem,
): ProjectSelection {
if (selection.projectKey === project.projectKey && selection.project === project) {
return selection;
}
return {
...selection,
projectKey: project.projectKey,
project,
};
}
function shouldResetInitialFallbackSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): boolean {
if (
selection.source !== "initial" ||
!context.initialProject ||
context.initialProjectSource !== "lastActive"
) {
return false;
}
return selection.projectKey !== context.initialProject.projectKey;
}
export function resolveProjectSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): HostProjectListItem | null {
const projectKey = resolveProjectSelectionKey(selection);
if (!projectKey) {
return null;
}
const selectableProject = context.projects.find((project) => project.projectKey === projectKey);
if (selectableProject) {
return selectableProject;
}
if (
selection.project?.projectKey === projectKey &&
context.shouldPreserveMissingProject(selection.project)
) {
return selection.project;
}
if (selection.source !== "manual") {
return resolveSelectedProjectFromInitialInputs(projectKey, context);
}
return null;
}
export function reconcileProjectSelection(
current: ProjectSelection,
context: ProjectSelectionContext,
): ProjectSelection {
const initialSelection = createProjectSelection(context);
const currentContextKey =
current.source === "manual" ? context.manualContextKey : context.contextKey;
if (current.contextKey !== currentContextKey) {
return initialSelection;
}
if (shouldResetInitialFallbackSelection(current, context)) {
return initialSelection;
}
const resolvedProject = resolveProjectSelection(current, context);
if (resolvedProject) {
return refreshSelectionProject(current, resolvedProject);
}
return initialSelection;
}

View File

@@ -1,385 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactElement,
} from "react";
import { ScrollView, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { Plus } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { MenuHeader } from "@/components/headers/menu-header";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
useSchedules,
type AggregatedSchedule,
type ScheduleHostError,
} from "@/hooks/use-schedules";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
resolveSchedule,
type ScheduleBucket,
type ScheduleTargetAgent,
} from "@/schedules/schedule-derivation";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
} from "@/schedules/schedule-project-targets";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
type FormState =
| { mode: "closed" }
| { mode: "create" }
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
];
export function SchedulesScreen(): ReactElement {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={styles.container} />;
}
return <SchedulesScreenContent />;
}
function SchedulesScreenContent(): ReactElement {
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
const { agents } = useAggregatedAgents({ includeArchived: true });
const { projects } = useProjects();
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// Per-host agent-directory readiness from the runtime, not the aggregate agent
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
// agents, so a still-loading host would falsely mark its agent-target
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
// host's directory has loaded at least once.
const agentDirReadyHosts = useMemo(() => {
void runtimeVersion;
const ready = new Set<string>();
for (const host of hosts) {
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
ready.add(host.serverId);
}
}
return ready;
}, [hosts, runtime, runtimeVersion]);
const [form, setForm] = useState<FormState>({ mode: "closed" });
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
const openEdit = useCallback((schedule: AggregatedSchedule) => {
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
}, []);
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
const agentsByKey = useMemo(() => {
const map = new Map<string, ScheduleTargetAgent>();
for (const agent of agents) {
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
}
return map;
}, [agents]);
const projectNameByCwd = useMemo(
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
[projects],
);
// Resolve every schedule's derived state and target line once, then partition
// by the host and status filters. Sorted newest-first for a stable order
// across hosts.
const resolvedRows = useMemo(() => {
const now = Date.now();
return schedules.map((schedule) => ({
schedule,
resolved: resolveSchedule({
schedule,
serverId: schedule.serverId,
now,
agentsByKey,
projectNameByCwd,
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
}),
}));
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
const visibleRows = useMemo<ScheduleRowView[]>(() => {
const singleHost = hosts.length <= 1;
return resolvedRows
.filter(
({ schedule, resolved }) =>
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
resolved.bucket === statusFilter,
)
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
.map(({ schedule, resolved }) => ({
schedule,
targetLabel: resolved.target.label,
provider: resolved.target.provider,
state: resolved.state,
serverName: schedule.serverName,
singleHost,
}));
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
const showLoadError = isError && schedules.length === 0;
const showHostFilter = hosts.length > 1;
return (
<View style={styles.container}>
<MenuHeader title="Schedules" />
<SchedulesScreenBody
rows={visibleRows}
hostErrors={hostErrors}
hasSchedules={schedules.length > 0}
isInitialLoad={isInitialLoad}
showLoadError={showLoadError}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
showHostFilter={showHostFilter}
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
onRetry={refetch}
onCreate={openCreate}
onEdit={openEdit}
/>
<ScheduleFormSheet
serverId={form.mode === "edit" ? form.serverId : undefined}
visible={form.mode === "create" || form.mode === "edit"}
onClose={closeForm}
mode={form.mode === "edit" ? "edit" : "create"}
schedule={form.mode === "edit" ? form.schedule : undefined}
/>
</View>
);
}
function SchedulesScreenBody({
rows,
hostErrors,
hasSchedules,
isInitialLoad,
showLoadError,
statusFilter,
onStatusFilterChange,
showHostFilter,
hosts,
selectedHost,
onSelectHost,
onRetry,
onCreate,
onEdit,
}: {
rows: ScheduleRowView[];
hostErrors: ScheduleHostError[];
hasSchedules: boolean;
isInitialLoad: boolean;
showLoadError: boolean;
statusFilter: ScheduleBucket;
onStatusFilterChange: (value: ScheduleBucket) => void;
showHostFilter: boolean;
hosts: ReturnType<typeof useHosts>;
selectedHost: string;
onSelectHost: (serverId: string) => void;
onRetry: () => void;
onCreate: () => void;
onEdit: (schedule: AggregatedSchedule) => void;
}): ReactElement {
if (isInitialLoad) {
return (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
</View>
);
}
if (showLoadError) {
return (
<View style={styles.centered}>
<Text style={styles.message}>Unable to load schedules</Text>
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
Try again
</Button>
</View>
);
}
if (!hasSchedules) {
return (
<View style={styles.centered} testID="schedules-empty">
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<Text style={styles.message}>No schedules yet</Text>
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
</View>
);
}
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
return (
<View style={styles.body}>
<View style={styles.filterRow}>
<View style={styles.filterRowControls}>
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={onSelectHost}
triggerTestID="schedules-host-filter-trigger"
/>
) : null}
<SegmentedControl
size="sm"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={STATUS_FILTER_OPTIONS}
testID="schedules-status-filter"
/>
</View>
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
New schedule
</Button>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
</ScrollView>
</View>
);
}
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="schedules-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`${error.serverName}: Could not load schedules`}
</Text>
))}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
minHeight: 0,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
filterRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingTop: theme.spacing[4],
},
filterRowControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
flexShrink: 1,
flexWrap: "wrap",
},
scroll: {
flex: 1,
minHeight: 0,
},
scrollContent: {
gap: theme.spacing[3],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
errorsBannerWrap: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
filterEmpty: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingVertical: theme.spacing[6],
alignItems: "center",
},
filterEmptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
message: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
// Static color holder read by the spinner; keeps the muted token without
// useUnistyles (banned in new code).
spinner: {
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -1,18 +1,23 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
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 { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen() {
@@ -25,6 +30,71 @@ export function SessionsScreen() {
return <SessionsScreenContent />;
}
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -82,11 +152,10 @@ function SessionsScreenContent() {
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<HostFilter
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
</View>
) : null}
@@ -138,6 +207,32 @@ const styles = StyleSheet.create((theme) => ({
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",

View File

@@ -1,70 +0,0 @@
import React, { useCallback } from "react";
import { Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import {
createBrowserToolsPatch,
getBrowserToolsCardState,
getBrowserToolsMutationViewState,
} from "./browser-tools-config";
export function BrowserToolsOptInCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const state = getBrowserToolsCardState({ isConnected, config });
const mutation = useMutation({
mutationFn: async (next: boolean) => {
const result = await patchConfig(createBrowserToolsPatch(next));
if (!result) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return result;
},
});
const mutationView = getBrowserToolsMutationViewState({
isPending: mutation.isPending,
error: mutation.error,
});
const handleValueChange = useCallback(
(next: boolean) => {
mutation.mutate(next);
},
[mutation],
);
if (!state.isVisible) return null;
return (
<View style={settingsStyles.card} testID="host-page-browser-tools-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{state.title}</Text>
<Text style={settingsStyles.rowHint}>{state.warning}</Text>
{mutationView.loadingText ? (
<Text style={settingsStyles.rowHint} testID="host-page-browser-tools-loading">
{mutationView.loadingText}
</Text>
) : null}
{mutationView.errorText ? (
<Text style={settingsStyles.rowError} testID="host-page-browser-tools-error">
{mutationView.errorText}
</Text>
) : null}
</View>
<Switch
value={state.isEnabled}
onValueChange={handleValueChange}
disabled={mutationView.isSwitchDisabled}
accessibilityLabel="Enable browser tools"
testID="host-page-browser-tools-switch"
/>
</View>
</View>
);
}

View File

@@ -1,70 +0,0 @@
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import {
BROWSER_TOOLS_WARNING,
createBrowserToolsPatch,
getBrowserToolsCardState,
getBrowserToolsMutationViewState,
} from "./browser-tools-config";
function makeConfig(browserToolsEnabled = false): MutableDaemonConfig {
return {
mcp: { injectIntoAgents: false },
browserTools: { enabled: browserToolsEnabled },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
};
}
describe("browser tools opt-in config", () => {
it("shows the card with the logged-in browser state warning when connected", () => {
expect(getBrowserToolsCardState({ isConnected: true, config: makeConfig(false) })).toEqual({
isVisible: true,
isEnabled: false,
title: "Browser tools",
warning: BROWSER_TOOLS_WARNING,
});
});
it("reads enabled state from daemon config", () => {
expect(getBrowserToolsCardState({ isConnected: true, config: makeConfig(true) })).toMatchObject(
{
isEnabled: true,
},
);
});
it("hides the card when the host is disconnected", () => {
expect(
getBrowserToolsCardState({ isConnected: false, config: makeConfig(true) }),
).toMatchObject({
isVisible: false,
});
});
it("writes daemon.browserTools.enabled when toggled", () => {
expect(createBrowserToolsPatch(true)).toEqual({ browserTools: { enabled: true } });
expect(createBrowserToolsPatch(false)).toEqual({ browserTools: { enabled: false } });
});
it("shows loading and disables the toggle while browser tool settings save", () => {
expect(getBrowserToolsMutationViewState({ isPending: true, error: null })).toEqual({
isSwitchDisabled: true,
loadingText: "Updating browser tools…",
errorText: null,
});
});
it("shows the save error when browser tool settings fail", () => {
expect(
getBrowserToolsMutationViewState({ isPending: false, error: new Error("Disk full") }),
).toEqual({
isSwitchDisabled: false,
loadingText: null,
errorText: "Disk full",
});
});
});

View File

@@ -1,49 +0,0 @@
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
export const BROWSER_TOOLS_TITLE = "Browser tools";
export const BROWSER_TOOLS_WARNING =
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
export interface BrowserToolsCardState {
isVisible: boolean;
isEnabled: boolean;
title: string;
warning: string;
}
export interface BrowserToolsMutationViewState {
isSwitchDisabled: boolean;
loadingText: string | null;
errorText: string | null;
}
export function getBrowserToolsCardState(input: {
isConnected: boolean;
config: MutableDaemonConfig | null;
}): BrowserToolsCardState {
return {
isVisible: input.isConnected,
isEnabled: input.config?.browserTools.enabled === true,
title: BROWSER_TOOLS_TITLE,
warning: BROWSER_TOOLS_WARNING,
};
}
export function createBrowserToolsPatch(enabled: boolean): Partial<MutableDaemonConfig> {
return { browserTools: { enabled } };
}
export function getBrowserToolsMutationViewState(input: {
isPending: boolean;
error: unknown;
}): BrowserToolsMutationViewState {
return {
isSwitchDisabled: input.isPending,
loadingText: input.isPending ? "Updating browser tools…" : null,
errorText: input.error ? toErrorMessage(input.error) : null,
};
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -61,7 +61,6 @@ import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
@@ -263,7 +262,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
{isConnected ? (
<SettingsSection title={t("settings.hostSections.agents")}>
<InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
</SettingsSection>
) : (

View File

@@ -220,7 +220,6 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return {
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers,
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,

View File

@@ -105,7 +105,6 @@ import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { removeResidentBrowserWebview } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
@@ -305,22 +304,19 @@ function decodeSegment(value: string): string {
function useSyncWorkspaceActiveBrowser(input: {
workspaceLayout: WorkspaceLayout | null;
isRouteFocused: boolean;
workspaceId: string;
}) {
const focusedBrowserId = useMemo(
() => getFocusedBrowserId(input.workspaceLayout),
[input.workspaceLayout],
);
const desktopActiveBrowserId = input.isRouteFocused ? focusedBrowserId : null;
useEffect(() => {
if (!getIsElectron()) {
return;
}
void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.({
workspaceId: input.workspaceId,
browserId: focusedBrowserId,
});
}, [focusedBrowserId, input.workspaceId]);
void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.(desktopActiveBrowserId);
}, [desktopActiveBrowserId]);
}
function getFallbackTabOptionLabel(
@@ -1880,11 +1876,7 @@ function WorkspaceScreenContent({
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
);
useSyncWorkspaceActiveBrowser({
workspaceLayout,
isRouteFocused,
workspaceId: normalizedWorkspaceId,
});
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
);
@@ -1929,7 +1921,6 @@ function WorkspaceScreenContent({
if (input.target?.kind === "browser") {
const { browserId } = input.target;
useBrowserStore.getState().removeBrowser(browserId);
removeResidentBrowserWebview(browserId);
void getDesktopHost()?.browser?.clearPartition?.(browserId);
}
closeWorkspaceTab(persistenceKey, normalizedTabId);

View File

@@ -1,5 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
@@ -23,14 +22,10 @@ interface BrowserStoreState extends BrowserIndexState {
}
function createBrowserId(): string {
let browserId: string;
if (typeof globalThis.crypto?.randomUUID === "function") {
browserId = globalThis.crypto.randomUUID();
} else {
const randomSuffix = Math.random().toString(16).slice(2) || "0";
browserId = `${Date.now()}-${randomSuffix}`;
return globalThis.crypto.randomUUID();
}
return BrowserAutomationBrowserIdSchema.parse(browserId);
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(

View File

@@ -9,7 +9,6 @@ import {
} from "@/attachments/service";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
applyClearDraftRecord,
collectReferencedAttachmentIdsFromState,
@@ -142,18 +141,6 @@ async function runAttachmentGc(): Promise<void> {
collectStreamUserImageIds(session.agentStreamHead, referencedIds);
}
// Browser-element screenshots live in the workspace attachment store, not in
// drafts, so collect their ids here to keep them from being garbage collected
// before the user sends the message.
const attachmentsByScope = useWorkspaceAttachmentsStore.getState().attachmentsByScope;
for (const attachments of Object.values(attachmentsByScope)) {
for (const attachment of attachments) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
referencedIds.add(attachment.attachment.screenshot.id);
}
}
}
try {
await garbageCollectAttachments({ referencedIds });
} catch (error) {

View File

@@ -20,6 +20,10 @@ import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navig
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
@@ -29,7 +33,13 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
lastWorkspaceSelectionStorage,
);
function navigateDeps(): NavigateToWorkspaceDeps {
function shouldPopToExistingHostRoute(options: NavigateToWorkspaceOptions): boolean {
// Only /new needs POP_TO to avoid hidden deck entries; regular workspace switches
// should use router navigation so route observers like the sidebar selection update.
return options.currentPathname === "/new";
}
function navigateDeps(options: NavigateToWorkspaceOptions): NavigateToWorkspaceDeps {
return {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
@@ -39,7 +49,9 @@ function navigateDeps(): NavigateToWorkspaceDeps {
},
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => {
navigateToHostWorkspaceRoute(route);
navigateToHostWorkspaceRoute(route, {
popToExistingHostRoute: shouldPopToExistingHostRoute(options),
});
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
},
};
@@ -57,13 +69,17 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated();
}
export function navigateToWorkspace(serverId: string, workspaceId: string) {
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
options: NavigateToWorkspaceOptions = {},
) {
navigateToWorkspacePure(serverId, workspaceId, navigateDeps(options));
}
export function navigateToLastWorkspace(): boolean {
return navigateToLastWorkspacePure({
...navigateDeps(),
...navigateDeps({}),
getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(),
});
}

View File

@@ -115,18 +115,6 @@ describe("workspace navigation", () => {
});
});
it("ignores stale workspace route params while an app-wide route is active", () => {
const selection = parseActiveWorkspaceSelection({
pathname: "/settings/general",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
},
});
expect(selection).toBeNull();
});
it("navigates to the last workspace once a route observation has been remembered", () => {
const { deps, navigations } = createLastSelectionDeps(null);

View File

@@ -58,16 +58,10 @@ function parseWorkspaceSelectionFromRouteParams(params: {
export function parseActiveWorkspaceSelection(
input: RouteSelectionInput,
): ActiveWorkspaceSelection | null {
const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
if (routeSelection) {
return routeSelection;
}
if (input.pathname !== "/" && input.pathname !== "") {
return null;
}
return parseWorkspaceSelectionFromRouteParams(input.params);
return (
parseHostWorkspaceRouteFromPathname(input.pathname) ??
parseWorkspaceSelectionFromRouteParams(input.params)
);
}
export function navigateToWorkspace(

View File

@@ -63,9 +63,4 @@ export const settingsStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
rowError: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
}));

View File

@@ -25,7 +25,7 @@ describe("assistant image metadata", () => {
setAssistantImageMetadata(
{
source: "/tmp/paseo-codex-screenshot.png",
workspaceRoot: "/workspaces/paseo",
workspaceRoot: "/Users/moboudra/dev/paseo",
serverId: "server-1",
},
{ width: 1200, height: 800 },

View File

@@ -5,28 +5,28 @@ describe("buildAbsoluteExplorerPath", () => {
it("builds a POSIX absolute path from a relative explorer path", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/workspaces/paseo",
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
}),
).toBe("/workspaces/paseo/packages/app/src/components/file-explorer-pane.tsx");
).toBe("/Users/moboudra/dev/paseo/packages/app/src/components/file-explorer-pane.tsx");
});
it("returns workspace root when entry path points to explorer root", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/workspaces/paseo",
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: ".",
}),
).toBe("/workspaces/paseo");
).toBe("/Users/moboudra/dev/paseo");
});
it("trims trailing separators from workspace root before joining", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/workspaces/paseo/",
workspaceRoot: "/Users/moboudra/dev/paseo/",
entryPath: "README.md",
}),
).toBe("/workspaces/paseo/README.md");
).toBe("/Users/moboudra/dev/paseo/README.md");
});
it("builds a Windows absolute path with backslash separators", () => {
@@ -41,7 +41,7 @@ describe("buildAbsoluteExplorerPath", () => {
it("passes through an already-absolute entry path", () => {
expect(
buildAbsoluteExplorerPath({
workspaceRoot: "/workspaces/paseo",
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "/tmp/another/location.txt",
}),
).toBe("/tmp/another/location.txt");

View File

@@ -45,8 +45,8 @@ describe("workspace route parsing", () => {
});
it("decodes non-canonical base64url workspace IDs used by older links", () => {
expect(decodeWorkspaceIdFromPathSegment("L2hvbWUvdXNlci9kZXYvcGFzZW8")).toBe(
"/home/user/dev/paseo",
expect(decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")).toBe(
"/Users/moboudra/dev/paseo",
);
});

View File

@@ -415,10 +415,6 @@ export function buildSessionsRoute() {
return "/sessions" as const;
}
export function buildSchedulesRoute() {
return "/schedules" as const;
}
export function buildOpenProjectRoute() {
return "/open-project" as const;
}

View File

@@ -69,6 +69,7 @@ describe("resolveNavigateToAgent", () => {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
currentPathname: undefined,
pin: true,
},
]);
@@ -106,6 +107,7 @@ describe("resolveNavigateToAgent", () => {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
currentPathname: undefined,
pin: undefined,
},
]);

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