mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
v0.1.104-b
...
dotfile-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 |
12
.github/workflows/desktop-release.yml
vendored
12
.github/workflows/desktop-release.yml
vendored
@@ -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: |
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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
|
||||
|
||||
57
CLAUDE.md
57
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||
|
||||
374
package-lock.json
generated
374
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104-beta.1",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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 />
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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] },
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: "إعداد البرامج النصية لشجرة العمل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ワークツリースクリプトを設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Настройка сценариев рабочего дерева",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>()(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user