mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 |
@@ -329,6 +329,18 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 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
|
## 0.1.102 - 2026-06-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [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/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/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/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||||
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): model-provider catalog, generic config, OAuth store, CLI/app setup |
|
|
||||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [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/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/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
|
|||||||
@@ -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.
|
> **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 and agent automation targets are intentionally separate: the workspace-active browser follows the user's focused tab, while the agent-active browser is the default target for browser MCP commands. 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
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -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.
|
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.
|
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
|
### React render profiling
|
||||||
|
|
||||||
The app has a gated React render profiler in
|
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
|
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
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
|
## paseo.json service scripts
|
||||||
|
|
||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
|
|||||||
+7
-15
@@ -47,27 +47,18 @@ dynamic params exist before any nested workspace leaf is selected.
|
|||||||
|
|
||||||
## App-Wide Route Hops
|
## App-Wide Route Hops
|
||||||
|
|
||||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||||
into a host workspace, express only the destination with `navigateToWorkspace()`.
|
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||||
Do not make the caller branch on its current route.
|
leaf workspace URL.
|
||||||
|
|
||||||
The root stack owns `h/[serverId]`; the host stack owns
|
The root stack owns `h/[serverId]`; the host stack owns
|
||||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
`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
|
root host route and pass the nested workspace screen, or Expo Router can append
|
||||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
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.
|
|
||||||
|
|
||||||
Those hidden entries are not harmless: composer floating panels can measure
|
Those hidden entries are not harmless: composer floating panels can measure
|
||||||
against the wrong deck and disappear offscreen.
|
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
|
## Params
|
||||||
|
|
||||||
Required dynamic params belong to the matched route.
|
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 change `packages/app/src/app`? Re-read this file.
|
||||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
- [ ] 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 you add a route? Register it in the layout that directly owns it.
|
||||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||||
|
|||||||
+4
-9
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||||
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
|
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||||
@@ -17,13 +17,11 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||||
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||||
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
|
|
||||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||||
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
|
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||||
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
|
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||||
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
|
||||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||||
@@ -41,8 +39,5 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
|
|
||||||
## Inconsistencies (documented, not papered over)
|
## Inconsistencies (documented, not papered over)
|
||||||
|
|
||||||
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
|
|
||||||
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
|
|
||||||
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
|
|
||||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- `opencode --version`: `1.14.46`
|
- `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`
|
- `node --version`: `v22.20.0`
|
||||||
- `npm --version`: `10.9.3`
|
- `npm --version`: `10.9.3`
|
||||||
|
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
# Paseo Agent provider
|
|
||||||
|
|
||||||
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
|
|
||||||
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
|
|
||||||
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
|
|
||||||
|
|
||||||
Use it like any other agent provider:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
|
|
||||||
instance and the `openai/gpt-4o-mini` model exposed by that instance.
|
|
||||||
|
|
||||||
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
|
|
||||||
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
|
|
||||||
> smoking, otherwise stale `dist` may reject or omit `agents.paseo` behavior. Always pass
|
|
||||||
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
|
|
||||||
> daemon on `:6767`.
|
|
||||||
|
|
||||||
## Timeline and persistence
|
|
||||||
|
|
||||||
Paseo Agent does not write or resume Pi CLI session files such as
|
|
||||||
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
|
|
||||||
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
|
|
||||||
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
|
|
||||||
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
|
|
||||||
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
|
|
||||||
|
|
||||||
## Provider catalog
|
|
||||||
|
|
||||||
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
|
|
||||||
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
|
|
||||||
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
|
|
||||||
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
|
|
||||||
thinking maps, and OAuth registry data at runtime.
|
|
||||||
|
|
||||||
The current catalog contains four entries:
|
|
||||||
|
|
||||||
| id | Pi provider | default models | auth source |
|
|
||||||
| ------------- | -------------- | -------------- | --------------------------------------------- |
|
|
||||||
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
|
|
||||||
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
|
|
||||||
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
|
|
||||||
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
|
|
||||||
|
|
||||||
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
|
|
||||||
Users can store the OpenRouter credential first, but they must choose explicit model ids
|
|
||||||
before running Paseo Agent through that provider. The other catalog entries expose Pi's
|
|
||||||
bundled model list unless an instance sets `options.models`.
|
|
||||||
|
|
||||||
## Config shape
|
|
||||||
|
|
||||||
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
|
|
||||||
keys; provider types are catalog ids. Use the catalog id as the default instance name,
|
|
||||||
or create several instances of the same type with different models, keys, or endpoint
|
|
||||||
overrides.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"paseo": {
|
|
||||||
"defaultAgent": "orchestrator",
|
|
||||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
|
||||||
"providers": {
|
|
||||||
"openrouter-main": {
|
|
||||||
"type": "openrouter",
|
|
||||||
"options": {
|
|
||||||
"apiKey": "$OPENROUTER_API_KEY",
|
|
||||||
"models": [
|
|
||||||
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
|
|
||||||
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"chatgpt": {
|
|
||||||
"type": "chatgpt",
|
|
||||||
},
|
|
||||||
"kimi": {
|
|
||||||
"type": "kimi",
|
|
||||||
"options": {
|
|
||||||
"apiKey": "$KIMI_API_KEY",
|
|
||||||
"models": [{ "id": "k2p7" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Most options are overrides over Pi-derived provider data:
|
|
||||||
|
|
||||||
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
|
|
||||||
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
|
|
||||||
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
|
|
||||||
mirrors Pi's config-value semantics: literals and commands count as configured; env
|
|
||||||
references count only when every referenced env var is set in the daemon environment.
|
|
||||||
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
|
|
||||||
config.
|
|
||||||
- `models[]` is an instance override. Omit it to use that entry's default policy, which
|
|
||||||
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
|
|
||||||
when a single backend serves mixed protocols or when Pi has no data for a custom id.
|
|
||||||
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
|
|
||||||
|
|
||||||
Env references make config portable: `config.json` can be copied between machines while
|
|
||||||
the secret stays in that machine's daemon environment or keychain command.
|
|
||||||
|
|
||||||
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
|
|
||||||
explicit session model wins, then the selected agent definition's model, then
|
|
||||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
|
||||||
|
|
||||||
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
|
|
||||||
Redacted provider responses include an optional `auth` state:
|
|
||||||
|
|
||||||
- `Connected` means the key or credential expression resolves locally. It does not make a
|
|
||||||
network call, so a fake literal key still reports connected until a real session uses it.
|
|
||||||
- `Needs attention` means the instance exists but the auth expression does not currently
|
|
||||||
resolve, the OAuth store binding does not match, or another auth precondition is missing.
|
|
||||||
- `not configured` is used by older/no-auth responses.
|
|
||||||
|
|
||||||
The redacted provider `available` flag mirrors local credential availability. The Paseo
|
|
||||||
Agent runtime still needs at least one exposed model before it can start a session.
|
|
||||||
|
|
||||||
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
|
|
||||||
output.
|
|
||||||
|
|
||||||
### OAuth store
|
|
||||||
|
|
||||||
OAuth credentials live in Paseo's store, not in another tool's auth file:
|
|
||||||
|
|
||||||
```text
|
|
||||||
$PASEO_HOME/paseo-agent/auth.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The store is created through Pi's `AuthStorage`. The parent directory is private and the
|
|
||||||
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
|
|
||||||
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
|
|
||||||
rotation back into the same Paseo-owned file.
|
|
||||||
|
|
||||||
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
|
|
||||||
catalog flow or configured base URL changes, the old credential is left on disk but the
|
|
||||||
provider reports `Needs attention` until it is authorized again. This prevents a token
|
|
||||||
for one OAuth target from silently being used against another.
|
|
||||||
|
|
||||||
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
|
|
||||||
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
|
|
||||||
The current catalog only uses `openai-codex` for `chatgpt`.
|
|
||||||
|
|
||||||
## CLI setup
|
|
||||||
|
|
||||||
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
|
|
||||||
isolated daemon.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
|
|
||||||
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
|
|
||||||
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
|
|
||||||
`--device-code`, `--json`, and `--host` as needed.
|
|
||||||
- `paseo provider ls` lists configured instances and redacted auth state.
|
|
||||||
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
|
|
||||||
it pointed at that instance.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n' "$OPENROUTER_API_KEY" |
|
|
||||||
paseo provider add \
|
|
||||||
openrouter \
|
|
||||||
--api-key-stdin \
|
|
||||||
--model openai/gpt-4o-mini \
|
|
||||||
--host 127.0.0.1:7911
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paseo provider add kimi \
|
|
||||||
--model k2p7 \
|
|
||||||
--host 127.0.0.1:7911
|
|
||||||
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
|
|
||||||
```
|
|
||||||
|
|
||||||
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
|
|
||||||
API-key providers it writes the configured key expression into `config.json`. For OAuth
|
|
||||||
providers it first writes the provider config, then stores the OAuth credential in the
|
|
||||||
daemon's Paseo-owned auth store.
|
|
||||||
|
|
||||||
`provider add` is idempotent for the same instance name: running it again updates the
|
|
||||||
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
|
|
||||||
provider instance and clears `defaultModel` if it pointed at the removed instance.
|
|
||||||
|
|
||||||
## App setup
|
|
||||||
|
|
||||||
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
|
|
||||||
settings, then the Paseo Agent provider section. The app fetches the catalog from the
|
|
||||||
connected daemon, shows catalog entries in the picker, and renders either an API-key
|
|
||||||
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
|
|
||||||
daemon-run starts: browser redirect for the local fast path and device code for
|
|
||||||
remote-safe authorization. Relay connections present device code first; direct TCP does
|
|
||||||
not prove same-machine by itself, so both options remain available.
|
|
||||||
|
|
||||||
Configured rows show the redacted provider state from the daemon: label, provider type,
|
|
||||||
models, availability, and auth state. The app gates this UI on
|
|
||||||
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
|
|
||||||
instead of trying to synthesize the feature through older RPCs.
|
|
||||||
|
|
||||||
## Wire surface
|
|
||||||
|
|
||||||
The catalog surface is gated by:
|
|
||||||
|
|
||||||
```text
|
|
||||||
server_info.features.paseoAgentCatalog
|
|
||||||
```
|
|
||||||
|
|
||||||
RPC names use dotted namespaces:
|
|
||||||
|
|
||||||
| request | response |
|
|
||||||
| --------------------------------------------------- | ---------------------------------------------------- |
|
|
||||||
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
|
|
||||||
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
|
|
||||||
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
|
|
||||||
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
|
|
||||||
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
|
|
||||||
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
|
|
||||||
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
|
|
||||||
|
|
||||||
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
|
|
||||||
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
|
|
||||||
complete the localhost redirect on the daemon host. Browser mode returns
|
|
||||||
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
|
|
||||||
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
|
|
||||||
stores the credential, and returns the redacted `auth` state. Device-code mode returns
|
|
||||||
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
|
|
||||||
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
|
|
||||||
provider authorizes.
|
|
||||||
|
|
||||||
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
|
|
||||||
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
|
|
||||||
new catalog entries, and new clients can show "update host" only when the daemon lacks the
|
|
||||||
catalog feature.
|
|
||||||
|
|
||||||
## Adding a catalog entry
|
|
||||||
|
|
||||||
Adding a new model-provider type should be a data change:
|
|
||||||
|
|
||||||
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
|
|
||||||
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
|
|
||||||
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
|
|
||||||
should not expose its full model list by default.
|
|
||||||
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
|
|
||||||
resolution simple.
|
|
||||||
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
|
|
||||||
and CLI/app rendering if the new entry exercises a new shape.
|
|
||||||
|
|
||||||
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
|
|
||||||
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
|
|
||||||
addressing.
|
|
||||||
|
|
||||||
## MCP tools
|
|
||||||
|
|
||||||
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
|
|
||||||
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
|
|
||||||
the model. On session start the provider connects to each server, lists its tools, and
|
|
||||||
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
|
|
||||||
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
|
|
||||||
back to the model. Connections are torn down on session close. Servers that fail to
|
|
||||||
connect or list are logged and skipped rather than failing the session.
|
|
||||||
|
|
||||||
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
|
|
||||||
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed: MCP
|
|
||||||
servers come from Paseo's normal injection/config, not from `agents.paseo`.
|
|
||||||
|
|
||||||
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
|
|
||||||
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
|
|
||||||
|
|
||||||
## Agent definitions
|
|
||||||
|
|
||||||
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
|
|
||||||
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
|
|
||||||
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
|
|
||||||
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"paseo": {
|
|
||||||
"defaultAgent": "orchestrator",
|
|
||||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
|
||||||
"providers": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example `$PASEO_HOME/agents/orchestrator.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
name: Orchestrator
|
|
||||||
description: Coordinates work through Paseo-managed agents
|
|
||||||
prompt: extend
|
|
||||||
mcp: [paseo]
|
|
||||||
model: openrouter-main/openai/gpt-4o-mini
|
|
||||||
tools: [read, grep, paseo__list_agents, paseo__create_agent]
|
|
||||||
permissions:
|
|
||||||
- tool: paseo__archive_*
|
|
||||||
action: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
!{{./partials/collaboration.md}}
|
|
||||||
|
|
||||||
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
|
|
||||||
summarize handoffs clearly.
|
|
||||||
|
|
||||||
!{{./partials/review-rules.md}}
|
|
||||||
```
|
|
||||||
|
|
||||||
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
|
|
||||||
the append list. `prompt: override` uses the agent body as the custom base prompt, so
|
|
||||||
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
|
|
||||||
appended after the agent, and the daemon-level append prompt is appended last.
|
|
||||||
|
|
||||||
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
|
|
||||||
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
|
|
||||||
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
|
|
||||||
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
|
|
||||||
an explicit session model wins, then the selected agent model, then
|
|
||||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
|
||||||
|
|
||||||
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
|
|
||||||
Paths are relative to the file containing the directive and are confined to
|
|
||||||
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
|
|
||||||
chains, oversized definitions, and frontmatter inside partials are rejected.
|
|
||||||
|
|
||||||
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
|
|
||||||
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
|
|
||||||
an ordered first-match policy for active tool calls. The first matching `tool` pattern
|
|
||||||
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
|
|
||||||
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
|
|
||||||
|
|
||||||
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
|
|
||||||
MCP injection still supplies the actual server; if an agent declares an MCP server that
|
|
||||||
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
|
|
||||||
@@ -45,7 +45,6 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
nodejs = nodejs_22;
|
nodejs = nodejs_22;
|
||||||
inherit (paseo) npmDeps;
|
inherit (paseo) npmDeps;
|
||||||
npmDepsFetcherVersion = 2;
|
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
|
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ buildNpmPackage rec {
|
|||||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||||
inherit npmDepsHash;
|
inherit npmDepsHash;
|
||||||
npmDepsFetcherVersion = 2;
|
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+384
-2898
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.103",
|
"version": "0.1.102",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"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 { expect, test, type Page } from "./fixtures";
|
||||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||||
import {
|
import {
|
||||||
@@ -5,7 +7,7 @@ import {
|
|||||||
seedMockAgentWorkspace,
|
seedMockAgentWorkspace,
|
||||||
type MockAgentWorkspace,
|
type MockAgentWorkspace,
|
||||||
} from "./helpers/mock-agent";
|
} from "./helpers/mock-agent";
|
||||||
import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
|
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
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 });
|
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(
|
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
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(
|
async function visiblePopoverBox(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ top: number; bottom: number; height: number }> {
|
): Promise<{ top: number; bottom: number; height: number }> {
|
||||||
@@ -358,7 +372,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Composer autocomplete", () => {
|
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);
|
await installListCommandsStub(page);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const sessions: MockAgentWorkspace[] = [];
|
const sessions: MockAgentWorkspace[] = [];
|
||||||
@@ -391,28 +405,10 @@ test.describe("Composer autocomplete", () => {
|
|||||||
await openAppWideNewWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: 2,
|
|
||||||
serverId,
|
|
||||||
workspaceId: second.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openSettingsThenBackToWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: 2,
|
|
||||||
serverId,
|
|
||||||
workspaceId: second.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openSessions(page);
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||||
await expectComposerVisible(page, { timeout: 30_000 });
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
||||||
expectedDeckEntryCount: sessions.length,
|
|
||||||
serverId,
|
|
||||||
workspaceId: third.workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openAppWideNewWorkspace(page);
|
await openAppWideNewWorkspace(page);
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
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 }) => {
|
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||||
await installListCommandsStub(page);
|
await installListCommandsStub(page);
|
||||||
const agent = await openReadyMockAgent(page);
|
const agent = await openReadyMockAgent(page);
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { gotoAppShell, openSettings } from "./app";
|
|
||||||
import { connectDaemonClient } from "./daemon-client-loader";
|
|
||||||
import { getServerId } from "./server-id";
|
|
||||||
import { openSettingsHostSection } from "./settings";
|
|
||||||
|
|
||||||
type PaseoAgentDaemonClient = Pick<
|
|
||||||
InternalDaemonClient,
|
|
||||||
| "close"
|
|
||||||
| "connect"
|
|
||||||
| "removePaseoAgentProvider"
|
|
||||||
| "setPaseoAgentProvider"
|
|
||||||
| "storePaseoAgentOAuthCredential"
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface ApiKeyProviderInput {
|
|
||||||
catalogId: string;
|
|
||||||
apiKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OAuthProviderInput {
|
|
||||||
catalogId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExpectedProvider {
|
|
||||||
name: string;
|
|
||||||
providerLabel: string;
|
|
||||||
auth: "Connected" | "Needs attention";
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
|
|
||||||
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openPaseoAgentSettings(page: Page): Promise<void> {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await openSettingsHostSection(page, getServerId(), "providers");
|
|
||||||
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
|
|
||||||
const sheet = page.getByTestId("paseo-agent-settings-sheet");
|
|
||||||
await expect(sheet).toBeVisible();
|
|
||||||
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
|
||||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
|
||||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
|
||||||
await page.getByLabel("API key").fill(provider.apiKey);
|
|
||||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
|
||||||
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startOAuthProviderSignIn(
|
|
||||||
page: Page,
|
|
||||||
provider: OAuthProviderInput,
|
|
||||||
): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
|
||||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
|
||||||
await expect(
|
|
||||||
page.getByRole("button", { name: "Sign in with browser", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
|
|
||||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectModelProviderListed(
|
|
||||||
page: Page,
|
|
||||||
expected: ExpectedProvider,
|
|
||||||
): Promise<void> {
|
|
||||||
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
|
|
||||||
await expect(
|
|
||||||
page.getByRole("listitem", {
|
|
||||||
name: new RegExp(
|
|
||||||
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedChatGptProvider(providerName: string): Promise<void> {
|
|
||||||
const client = await connectPaseoAgentClient();
|
|
||||||
try {
|
|
||||||
await client.setPaseoAgentProvider({
|
|
||||||
name: providerName,
|
|
||||||
providerType: "chatgpt",
|
|
||||||
options: {
|
|
||||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await client.storePaseoAgentOAuthCredential({
|
|
||||||
name: providerName,
|
|
||||||
credential: {
|
|
||||||
type: "oauth",
|
|
||||||
access: "fake-access-token",
|
|
||||||
refresh: "fake-refresh-token",
|
|
||||||
expires: 4_102_444_800,
|
|
||||||
futureField: { passthrough: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
|
|
||||||
const client = await connectPaseoAgentClient();
|
|
||||||
try {
|
|
||||||
for (const name of providerNames) {
|
|
||||||
await client.removePaseoAgentProvider(name);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,6 @@ import type { WebSocketRoute } from "@playwright/test";
|
|||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
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 ---
|
// --- Navigation ---
|
||||||
|
|
||||||
export async function openProjects(page: Page): Promise<void> {
|
export async function openProjects(page: Page): Promise<void> {
|
||||||
@@ -194,39 +171,33 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
|||||||
|
|
||||||
// --- WebSocket helpers ---
|
// --- WebSocket helpers ---
|
||||||
|
|
||||||
// Proxies all daemon WS traffic transparently, but rejects paseo.json reads
|
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||||
// until the test explicitly allows recovery. Closing the transport leaves the
|
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||||
// client-side RPC pending across reconnects, so this injects the same correlated
|
// connections pass through so the Reload action can succeed.
|
||||||
// rpc_error shape the daemon emits for failed async session requests.
|
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||||
export async function installReadTransportFailure(
|
let armed = true;
|
||||||
page: Page,
|
|
||||||
): Promise<{ allowRecovery: () => void }> {
|
|
||||||
let shouldFailReads = true;
|
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
const sessionMessage = getSessionMessage(message);
|
if (armed && typeof message === "string") {
|
||||||
if (shouldFailReads && sessionMessage?.type === "read_project_config_request") {
|
try {
|
||||||
const requestId = sessionMessage.requestId;
|
const envelope = JSON.parse(message) as {
|
||||||
if (typeof requestId === "string") {
|
type?: string;
|
||||||
ws.send(
|
message?: { type?: string };
|
||||||
JSON.stringify({
|
};
|
||||||
type: "session",
|
if (
|
||||||
message: {
|
envelope.type === "session" &&
|
||||||
type: "rpc_error",
|
envelope.message?.type === "read_project_config_request"
|
||||||
payload: {
|
) {
|
||||||
requestId,
|
armed = false;
|
||||||
requestType: "read_project_config_request",
|
void ws.close({ code: 1001 });
|
||||||
error: "Test read transport failure.",
|
return;
|
||||||
code: "transport",
|
}
|
||||||
},
|
} catch {
|
||||||
},
|
// binary or malformed frame — pass through
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
server.send(message);
|
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
|
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||||
|
|||||||
@@ -10,51 +10,50 @@ export async function expectCurrentQuestion(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const card = page.getByTestId("question-form-card").first();
|
const card = page.getByTestId("question-form-card").first();
|
||||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||||
// Nav tabs only render for multi-question cards (hidden for a lone question).
|
await expect(
|
||||||
if (input.total > 1) {
|
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||||
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
|
).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options render as radios (single-select) or checkboxes (multi-select), so match
|
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||||
// either role by accessible name.
|
await page
|
||||||
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
|
|
||||||
.getByTestId("question-form-card")
|
.getByTestId("question-form-card")
|
||||||
.first()
|
.first()
|
||||||
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
|
.getByRole("button", { name: option })
|
||||||
}
|
.click();
|
||||||
|
|
||||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
|
||||||
await questionOption(page, option).click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
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(
|
export async function openQuestion(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { index: number; total: number },
|
input: { index: number; total: number },
|
||||||
): Promise<void> {
|
): 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(
|
export async function expectQuestionNavigationEnabled(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { index: number; total: number },
|
input: { index: number; total: number },
|
||||||
): Promise<void> {
|
): 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(
|
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,7 +10,6 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
// Model B entry points into the New Workspace screen. The per-project
|
// Model B entry points into the New Workspace screen. The per-project
|
||||||
@@ -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 ({
|
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "./fixtures";
|
|
||||||
import {
|
|
||||||
addApiKeyProvider,
|
|
||||||
cleanupPaseoAgentProviders,
|
|
||||||
expectModelProviderListed,
|
|
||||||
openPaseoAgentSettings,
|
|
||||||
seedChatGptProvider,
|
|
||||||
startOAuthProviderSignIn,
|
|
||||||
} from "./helpers/paseo-agent";
|
|
||||||
|
|
||||||
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
|
|
||||||
const CLOSE_PROVIDER = "phase-e-close-ui";
|
|
||||||
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
|
|
||||||
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
|
|
||||||
const RENAME_PROVIDER = "phase-e-rename-ui";
|
|
||||||
|
|
||||||
test.describe("Paseo Agent provider configuration", () => {
|
|
||||||
const providerNamesToCleanup = new Set<string>();
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
await cleanupPaseoAgentProviders(providerNamesToCleanup);
|
|
||||||
providerNamesToCleanup.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
|
||||||
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await addApiKeyProvider(page, {
|
|
||||||
catalogId: "openrouter",
|
|
||||||
apiKey: "sk-or-phase-e-write-only",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expectModelProviderListed(page, {
|
|
||||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
|
||||||
providerLabel: "OpenRouter",
|
|
||||||
modelCount: 0,
|
|
||||||
auth: "Connected",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
|
||||||
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
|
||||||
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
|
||||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
|
||||||
await page.getByLabel("API key").fill("sk-or-default-name");
|
|
||||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
|
||||||
|
|
||||||
await expectModelProviderListed(page, {
|
|
||||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
|
||||||
providerLabel: "OpenRouter",
|
|
||||||
modelCount: 0,
|
|
||||||
auth: "Connected",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
|
||||||
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await startOAuthProviderSignIn(page, {
|
|
||||||
catalogId: "chatgpt",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
|
||||||
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
|
||||||
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
|
||||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows user-facing provider picker rows", async ({ page }) => {
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
|
||||||
|
|
||||||
const picker = page.getByTestId("paseo-agent-provider-picker");
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId("paseo-agent-catalog-entry-openrouter")
|
|
||||||
.getByText("API key", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
|
|
||||||
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
|
|
||||||
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
|
||||||
|
|
||||||
await seedChatGptProvider(CHATGPT_PROVIDER);
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
|
|
||||||
await expectModelProviderListed(page, {
|
|
||||||
name: CHATGPT_PROVIDER,
|
|
||||||
providerLabel: "ChatGPT",
|
|
||||||
modelCount: 1,
|
|
||||||
auth: "Connected",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renames a configured provider row without changing its stored credential key", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
providerNamesToCleanup.add(RENAME_PROVIDER);
|
|
||||||
|
|
||||||
await seedChatGptProvider(RENAME_PROVIDER);
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
|
|
||||||
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
|
|
||||||
await page.getByLabel("Provider name").fill("Work account");
|
|
||||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
|
||||||
|
|
||||||
await expectModelProviderListed(page, {
|
|
||||||
name: "Work account",
|
|
||||||
providerLabel: "ChatGPT",
|
|
||||||
modelCount: 1,
|
|
||||||
auth: "Connected",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
|
|
||||||
providerNamesToCleanup.add(CLOSE_PROVIDER);
|
|
||||||
|
|
||||||
await seedChatGptProvider(CLOSE_PROVIDER);
|
|
||||||
await openPaseoAgentSettings(page);
|
|
||||||
await expectModelProviderListed(page, {
|
|
||||||
name: CLOSE_PROVIDER,
|
|
||||||
providerLabel: "ChatGPT",
|
|
||||||
modelCount: 1,
|
|
||||||
auth: "Connected",
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByLabel("Close", { exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -282,9 +282,9 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
page,
|
page,
|
||||||
editableProject,
|
editableProject,
|
||||||
}) => {
|
}) => {
|
||||||
// Reject read_project_config_request calls until the user clicks Reload.
|
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||||
// This keeps automatic reconnect refetches from racing past the callout.
|
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||||
const transportFailure = await installReadTransportFailure(page);
|
await installReadTransportFailure(page);
|
||||||
|
|
||||||
await openProjects(page);
|
await openProjects(page);
|
||||||
await navigateToProjectSettings(page, editableProject.name);
|
await navigateToProjectSettings(page, editableProject.name);
|
||||||
@@ -292,8 +292,7 @@ test.describe("Projects settings — error UX", () => {
|
|||||||
await expectProjectSettingsError(page, "transport");
|
await expectProjectSettingsError(page, "transport");
|
||||||
await expectProjectSettingsFormHidden(page);
|
await expectProjectSettingsFormHidden(page);
|
||||||
|
|
||||||
// Retry Reload until the refetch wins any in-flight error-state rendering.
|
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||||
transportFailure.allowRecovery();
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await clickReloadProjectSettings(page);
|
await clickReloadProjectSettings(page);
|
||||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
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("ten-second-stream");
|
|
||||||
|
|
||||||
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,156 +0,0 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
|
||||||
import {
|
|
||||||
addFakeScheduleHostAndReload,
|
|
||||||
buildFakeScheduleHostWorkspace,
|
|
||||||
installFakeScheduleHost,
|
|
||||||
} from "./helpers/schedule-fake-host";
|
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
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-section-${getServerId()}`)).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New schedule" }).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-section-${fakeHost.serverId}`)).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await page.getByRole("button", { name: "New schedule" }).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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -256,7 +256,9 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
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 expectHostConnectingOrOffline(page);
|
||||||
await expectMenuButtonVisible(page);
|
await expectMenuButtonVisible(page);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.103",
|
"version": "0.1.102",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
|||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
|
||||||
useHostRegistryLoaded,
|
useHostRegistryLoaded,
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -134,13 +133,14 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
const target = resolveNotificationTarget(data);
|
const target = resolveNotificationTarget(data);
|
||||||
const serverId = target.serverId;
|
const serverId = target.serverId;
|
||||||
const agentId = target.agentId;
|
const agentId = target.agentId;
|
||||||
if (serverId && agentId) {
|
if (serverId && agentId) {
|
||||||
navigateToAgent({ serverId, agentId, pin: true });
|
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,9 +313,6 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
|||||||
if (!shouldUseDesktopDaemon()) {
|
if (!shouldUseDesktopDaemon()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasConfiguredLocalDaemonOverride()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const settings = await loadDesktopSettings();
|
const settings = await loadDesktopSettings();
|
||||||
return settings.daemon.manageBuiltInDaemon;
|
return settings.daemon.manageBuiltInDaemon;
|
||||||
}
|
}
|
||||||
@@ -892,7 +889,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
(pathname === "/open-project" ||
|
(pathname === "/open-project" ||
|
||||||
pathname === "/new" ||
|
pathname === "/new" ||
|
||||||
pathname === "/sessions" ||
|
pathname === "/sessions" ||
|
||||||
pathname === "/schedules" ||
|
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -951,7 +947,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="new" />
|
<Stack.Screen name="new" />
|
||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="schedules" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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 { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
@@ -17,6 +17,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
|
|
||||||
function HostAgentReadyRouteContent() {
|
function HostAgentReadyRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@@ -52,9 +53,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
navigateToAgent({
|
navigateToAgent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -94,6 +96,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,7 +114,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||||
|
|
||||||
return null;
|
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
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import type { BrowserAnnotationIntent, WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
@@ -36,13 +36,6 @@ function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment):
|
|||||||
return "Review";
|
return "Review";
|
||||||
}
|
}
|
||||||
|
|
||||||
const BROWSER_INTENT_LABEL_KEYS: Record<BrowserAnnotationIntent, string> = {
|
|
||||||
fix: "workspace.browser.annotate.intents.fix",
|
|
||||||
change: "workspace.browser.annotate.intents.change",
|
|
||||||
question: "workspace.browser.annotate.intents.question",
|
|
||||||
approve: "workspace.browser.annotate.intents.approve",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTextAttachmentSubtitle(
|
function getTextAttachmentSubtitle(
|
||||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
@@ -96,11 +89,10 @@ export function getWorkspaceAttachmentPillContent(
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
): AttachmentPillContent {
|
): AttachmentPillContent {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
const intent = attachment.attachment.intent;
|
|
||||||
return {
|
return {
|
||||||
icon: attachmentBrowserIcon,
|
icon: attachmentBrowserIcon,
|
||||||
title: attachment.attachment.tag,
|
title: attachment.attachment.tag,
|
||||||
subtitle: intent ? t(BROWSER_INTENT_LABEL_KEYS[intent]) : t("composer.attachments.element"),
|
subtitle: t("composer.attachments.element"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export interface AttachmentMetadata {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind of review feedback the user is attaching to a selected browser
|
|
||||||
* element, sent to the agent alongside the element context.
|
|
||||||
*/
|
|
||||||
export type BrowserAnnotationIntent = "fix" | "change" | "question" | "approve";
|
|
||||||
|
|
||||||
export interface BrowserElementAttachment {
|
export interface BrowserElementAttachment {
|
||||||
url: string;
|
url: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
@@ -48,16 +42,6 @@ export interface BrowserElementAttachment {
|
|||||||
} | null;
|
} | null;
|
||||||
parentChain: string[];
|
parentChain: string[];
|
||||||
children: string[];
|
children: string[];
|
||||||
/** Free-text review note the user wrote about this element, if any. */
|
|
||||||
comment?: string;
|
|
||||||
/** What the user wants the agent to do with this element, if annotated. */
|
|
||||||
intent?: BrowserAnnotationIntent;
|
|
||||||
/**
|
|
||||||
* 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;
|
formatted: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
||||||
import { mountBrowserAutomationHandler } from "./handler";
|
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
|
||||||
import {
|
|
||||||
buildWorkspaceTabPersistenceKey,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: {
|
|
||||||
navigate: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: {
|
|
||||||
getItem: vi.fn(async () => null),
|
|
||||||
setItem: vi.fn(async () => undefined),
|
|
||||||
removeItem: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "browser.automation.execute.request" }
|
|
||||||
>;
|
|
||||||
type BrowserAutomationExecuteResponse = Extract<
|
|
||||||
SessionInboundMessage,
|
|
||||||
{ type: "browser.automation.execute.response" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
class FakeDaemonClient {
|
|
||||||
public 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: "/repo",
|
|
||||||
command: { command: "new_tab", args: { workspaceId: "/repo", url: "https://example.com" } },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
|
||||||
return {
|
|
||||||
requestId,
|
|
||||||
ok: true as const,
|
|
||||||
result: {
|
|
||||||
command: "list_tabs" as const,
|
|
||||||
tabs: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
|
||||||
return {
|
|
||||||
requestId,
|
|
||||||
ok: true as const,
|
|
||||||
result: {
|
|
||||||
command: "list_tabs" as const,
|
|
||||||
tabs: currentBrowserTabs(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPromises(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForAsyncWork(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentBrowserTabs() {
|
|
||||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
|
||||||
browserId: browser.browserId,
|
|
||||||
workspaceId: "/repo",
|
|
||||||
url: browser.url,
|
|
||||||
title: browser.title,
|
|
||||||
isActive: true,
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mountBrowserAutomationHandler", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useBrowserStore.setState({ browsersById: {} });
|
|
||||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates an unfocused workspace browser tab for browser_new_tab", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
|
|
||||||
const setAgentActiveBrowser = vi.fn(async () => undefined);
|
|
||||||
const registerWorkspaceBrowser = vi.fn(async () => undefined);
|
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: "server-1",
|
|
||||||
workspaceId: "/repo",
|
|
||||||
});
|
|
||||||
if (!workspaceKey) throw new Error("expected workspace key");
|
|
||||||
const focusedTabId = useWorkspaceLayoutStore
|
|
||||||
.getState()
|
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
|
||||||
if (!focusedTabId) throw new Error("expected focused tab");
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () =>
|
|
||||||
({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand,
|
|
||||||
registerWorkspaceBrowser,
|
|
||||||
setWorkspaceActiveBrowser,
|
|
||||||
setAgentActiveBrowser,
|
|
||||||
},
|
|
||||||
}) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
const payload = client.sentResponses[0]?.payload;
|
|
||||||
expect(payload?.ok).toBe(true);
|
|
||||||
if (!payload?.ok) throw new Error("expected success");
|
|
||||||
expect(payload.result.command).toBe("new_tab");
|
|
||||||
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
|
|
||||||
expect(payload.result.url).toBe("https://example.com");
|
|
||||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toContainEqual(
|
|
||||||
expect.objectContaining({ target: { kind: "browser", browserId: payload.result.browserId } }),
|
|
||||||
);
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
||||||
expect(layout?.root.kind).toBe("pane");
|
|
||||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
|
||||||
expect(layout.root.pane.focusedTabId).toBe(focusedTabId);
|
|
||||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
|
||||||
browserId: payload.result.browserId,
|
|
||||||
workspaceId: "/repo",
|
|
||||||
});
|
|
||||||
expect(setAgentActiveBrowser).toHaveBeenCalledWith({
|
|
||||||
agentId: "agent-1",
|
|
||||||
browserId: payload.result.browserId,
|
|
||||||
});
|
|
||||||
expect(setWorkspaceActiveBrowser).not.toHaveBeenCalled();
|
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
|
||||||
browserId: payload.result.browserId,
|
|
||||||
url: "https://example.com",
|
|
||||||
});
|
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs: 1,
|
|
||||||
registrationPollIntervalMs: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
|
||||||
await waitForAsyncWork();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
|
||||||
requestId: "req-new",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_timeout",
|
|
||||||
retryable: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ url: "https://example.com" }),
|
|
||||||
);
|
|
||||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => {
|
|
||||||
throw new Error("IPC registration check failed");
|
|
||||||
});
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
requestId: "req-new",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unknown_error",
|
|
||||||
message: "IPC registration check failed",
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sends a success response from the desktop bridge", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: true as const,
|
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
|
||||||
expect(client.sentResponses).toEqual([
|
|
||||||
{
|
|
||||||
type: "browser.automation.execute.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: true,
|
|
||||||
result: { command: "list_tabs", tabs: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing bridge sends browser_unsupported", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(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 client = new FakeDaemonClient();
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand: async () => {
|
|
||||||
throw {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
|
||||||
retryable: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
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 client = new FakeDaemonClient();
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand: async () => {
|
|
||||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
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 requests", async () => {
|
|
||||||
const client = new FakeDaemonClient();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: true as const,
|
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
const unsubscribe = mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
|
||||||
expect(client.sentResponses).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
||||||
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
|
|
||||||
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } 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;
|
|
||||||
return 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 }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
await rememberAgentBrowserTarget({ request, browserHost });
|
|
||||||
|
|
||||||
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 ?? command.args.workspaceId;
|
|
||||||
if (!serverId || !workspaceId) {
|
|
||||||
return browserAutomationFailure({
|
|
||||||
requestId: request.requestId,
|
|
||||||
code: "browser_no_tab",
|
|
||||||
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_no_tab",
|
|
||||||
message: "Cannot create a browser tab without a workspace context.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
|
|
||||||
kind: "browser",
|
|
||||||
browserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
|
||||||
if (request.agentId) {
|
|
||||||
await browserHost?.setAgentActiveBrowser?.({ agentId: request.agentId, browserId });
|
|
||||||
}
|
|
||||||
|
|
||||||
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: { workspaceId: params.workspaceId } },
|
|
||||||
});
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rememberAgentBrowserTarget(input: {
|
|
||||||
request: BrowserAutomationExecuteRequest;
|
|
||||||
browserHost: DesktopHostBridge["browser"] | undefined;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!input.request.agentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const browserId = readRequestBrowserId(input.request);
|
|
||||||
if (!browserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await input.browserHost?.setAgentActiveBrowser?.({ agentId: input.request.agentId, browserId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRequestBrowserId(request: BrowserAutomationExecuteRequest): string | null {
|
|
||||||
if (request.browserId) {
|
|
||||||
return request.browserId;
|
|
||||||
}
|
|
||||||
const args = request.command.args as { browserId?: unknown };
|
|
||||||
return typeof args.browserId === "string" && args.browserId.length > 0 ? args.browserId : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 type { ReactNode, Ref } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||||
@@ -179,11 +178,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
desktopScrollContainer: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -457,11 +451,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
presentation?: "push" | "replace";
|
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({
|
export function AdaptiveModalSheet({
|
||||||
@@ -475,16 +464,11 @@ export function AdaptiveModalSheet({
|
|||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
presentation,
|
presentation,
|
||||||
webScrollbar = false,
|
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const desktopScrollRef = useRef<ScrollView>(null);
|
|
||||||
const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, {
|
|
||||||
enabled: webScrollbar && !isMobile,
|
|
||||||
});
|
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
const compactSafeAreaPadding = useMemo(
|
const compactSafeAreaPadding = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -590,22 +574,13 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<SheetHeaderView header={header} onClose={onClose} />
|
<SheetHeaderView header={header} onClose={onClose} />
|
||||||
{scrollable ? (
|
{scrollable ? (
|
||||||
<View style={styles.desktopScrollContainer}>
|
<ScrollView
|
||||||
<ScrollView
|
style={styles.desktopScroll}
|
||||||
ref={desktopScrollRef}
|
contentContainerStyle={styles.desktopContent}
|
||||||
style={styles.desktopScroll}
|
keyboardShouldPersistTaps="handled"
|
||||||
contentContainerStyle={styles.desktopContent}
|
>
|
||||||
keyboardShouldPersistTaps="handled"
|
{children}
|
||||||
onLayout={desktopScrollbar.onLayout}
|
</ScrollView>
|
||||||
onScroll={desktopScrollbar.onScroll}
|
|
||||||
onContentSizeChange={desktopScrollbar.onContentSizeChange}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
showsVerticalScrollIndicator={!webScrollbar}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollView>
|
|
||||||
{desktopScrollbar.overlay}
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopStaticContent}>{children}</View>
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
clearResidentBrowserWebviewsForTests,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
releaseResidentBrowserWebview,
|
|
||||||
removeResidentBrowserWebview,
|
|
||||||
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.width).toBe("1280px");
|
|
||||||
expect(webview.style.height).toBe("800px");
|
|
||||||
|
|
||||||
const reused = takeResidentBrowserWebview("browser-a");
|
|
||||||
|
|
||||||
expect(reused).toBe(webview);
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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>();
|
|
||||||
|
|
||||||
interface BrowserWebviewElement extends HTMLElement {
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
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";
|
|
||||||
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.getAttribute(BROWSER_ID_ATTRIBUTE) === browserId) {
|
|
||||||
return element as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyResidentWebviewStyle(webview: HTMLElement): void {
|
|
||||||
webview.style.display = "block";
|
|
||||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
|
||||||
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
|
||||||
webview.style.border = "0";
|
|
||||||
webview.style.background = "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
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 function removeResidentBrowserWebview(browserId: string): void {
|
|
||||||
const normalizedBrowserId = trimNonEmpty(browserId);
|
|
||||||
if (!normalizedBrowserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
|
||||||
resident?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearResidentBrowserWebviewsForTests(): void {
|
|
||||||
for (const webview of residentWebviewsByBrowserId.values()) {
|
|
||||||
webview.remove();
|
|
||||||
}
|
|
||||||
residentWebviewsByBrowserId.clear();
|
|
||||||
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
|
|
||||||
}
|
|
||||||
@@ -80,8 +80,6 @@ interface CombinedModelSelectorProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
hovered: boolean;
|
|
||||||
pressed: boolean;
|
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -89,15 +87,6 @@ interface CombinedModelSelectorProps {
|
|||||||
isRetryingProvider?: boolean;
|
isRetryingProvider?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
serverId?: string | null;
|
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 {
|
interface SelectorContentProps {
|
||||||
@@ -585,7 +574,6 @@ export function CombinedModelSelector({
|
|||||||
isRetryingProvider = false,
|
isRetryingProvider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
serverId = null,
|
serverId = null,
|
||||||
triggerFill = false,
|
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -705,26 +693,14 @@ export function CombinedModelSelector({
|
|||||||
}, [handleOpenChange, isOpen]);
|
}, [handleOpenChange, isOpen]);
|
||||||
|
|
||||||
const triggerStyle = useCallback(
|
const triggerStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
styles.trigger,
|
||||||
// hover/pressed state from the args, so the wrapper must not double-paint.
|
Boolean(hovered) && styles.triggerHovered,
|
||||||
if (triggerFill) {
|
(pressed || isOpen) && styles.triggerPressed,
|
||||||
return [
|
disabled && styles.triggerDisabled,
|
||||||
styles.trigger,
|
renderTrigger ? styles.customTriggerWrapper : null,
|
||||||
styles.customTriggerWrapper,
|
],
|
||||||
styles.triggerFill,
|
[disabled, isOpen, renderTrigger],
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBackToAll = useCallback(() => {
|
const handleBackToAll = useCallback(() => {
|
||||||
@@ -813,16 +789,12 @@ export function CombinedModelSelector({
|
|||||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
{renderTrigger({
|
||||||
renderTrigger({
|
selectedModelLabel: triggerLabel,
|
||||||
selectedModelLabel: triggerLabel,
|
onPress: handleTriggerPress,
|
||||||
onPress: handleTriggerPress,
|
disabled,
|
||||||
disabled,
|
isOpen,
|
||||||
isOpen,
|
})}
|
||||||
hovered: Boolean(hovered),
|
|
||||||
pressed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<ComboboxTrigger
|
<ComboboxTrigger
|
||||||
@@ -914,16 +886,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
height: "auto",
|
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: {
|
favoritesContainer: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import Svg, { Path } from "react-native-svg";
|
|
||||||
|
|
||||||
interface OpenRouterIconProps {
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
|
|
||||||
return (
|
|
||||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
|
||||||
<Path
|
|
||||||
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,5 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import {
|
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
|
||||||
CalendarClock,
|
|
||||||
FolderPlus,
|
|
||||||
History,
|
|
||||||
Home,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Server,
|
|
||||||
Settings,
|
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +56,6 @@ import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
|||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
buildSchedulesRoute,
|
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
buildSettingsAddHostRoute,
|
||||||
buildSettingsHostSectionRoute,
|
buildSettingsHostSectionRoute,
|
||||||
@@ -116,7 +105,6 @@ interface SidebarLabels {
|
|||||||
switchHost: string;
|
switchHost: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
schedules: string;
|
|
||||||
closeSidebar: string;
|
closeSidebar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +114,12 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
handleViewMoreNavigate: () => void;
|
handleViewMoreNavigate: () => void;
|
||||||
handleViewSchedulesNavigate: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleViewMore: () => void;
|
handleViewMore: () => void;
|
||||||
handleViewSchedules: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({
|
export const LeftSidebar = memo(function LeftSidebar({
|
||||||
@@ -235,10 +221,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
router.push(buildSessionsRoute());
|
router.push(buildSessionsRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewSchedulesNavigate = useCallback(() => {
|
|
||||||
router.push(buildSchedulesRoute());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||||
const labels = useMemo(
|
const labels = useMemo(
|
||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
@@ -249,7 +231,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
switchHost: t("sidebar.host.switchTitle"),
|
switchHost: t("sidebar.host.switchTitle"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
sessions: t("sidebar.sections.sessions"),
|
sessions: t("sidebar.sections.sessions"),
|
||||||
schedules: t("sidebar.sections.schedules"),
|
|
||||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
@@ -286,7 +267,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
handleAddHost={handleAddHostMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,7 +282,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
handleAddHost={handleAddHostDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
handleViewMore={handleViewMoreNavigate}
|
handleViewMore={handleViewMoreNavigate}
|
||||||
handleViewSchedules={handleViewSchedulesNavigate}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -578,11 +557,9 @@ function MobileSidebar({
|
|||||||
isOpen,
|
isOpen,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
handleViewSchedulesNavigate,
|
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -610,13 +587,6 @@ function MobileSidebar({
|
|||||||
handleViewMoreNavigate();
|
handleViewMoreNavigate();
|
||||||
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
||||||
|
|
||||||
const handleViewSchedules = useCallback(() => {
|
|
||||||
translateX.value = -windowWidth;
|
|
||||||
backdropOpacity.value = 0;
|
|
||||||
closeSidebar();
|
|
||||||
handleViewSchedulesNavigate();
|
|
||||||
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
const handleWorkspacePress = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [closeSidebar]);
|
}, [closeSidebar]);
|
||||||
@@ -777,14 +747,6 @@ function MobileSidebar({
|
|||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={CalendarClock}
|
|
||||||
label={labels.schedules}
|
|
||||||
onPress={handleViewSchedules}
|
|
||||||
isActive={isSchedulesActive}
|
|
||||||
testID="sidebar-schedules"
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -865,11 +827,9 @@ function DesktopSidebar({
|
|||||||
insetsTop,
|
insetsTop,
|
||||||
isOpen,
|
isOpen,
|
||||||
handleViewMore,
|
handleViewMore,
|
||||||
handleViewSchedules,
|
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -949,14 +909,6 @@ function DesktopSidebar({
|
|||||||
testID="sidebar-sessions"
|
testID="sidebar-sessions"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={CalendarClock}
|
|
||||||
label={labels.schedules}
|
|
||||||
onPress={handleViewSchedules}
|
|
||||||
isActive={isSchedulesActive}
|
|
||||||
testID="sidebar-schedules"
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<WorkspacesSectionHeader />
|
<WorkspacesSectionHeader />
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type {
|
|
||||||
PaseoAgentCatalogEntry,
|
|
||||||
RedactedPaseoAgentProviderConfig,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createPaseoAgentProviderInput,
|
|
||||||
getPaseoAgentApiKeyAuth,
|
|
||||||
getPaseoAgentOAuthAuth,
|
|
||||||
isPaseoAgentCatalogEntrySupported,
|
|
||||||
nextPaseoAgentProviderName,
|
|
||||||
parsePaseoAgentModelIds,
|
|
||||||
paseoAgentAuthBadge,
|
|
||||||
paseoAgentProviderLabel,
|
|
||||||
preferredPaseoAgentOAuthMode,
|
|
||||||
} from "./paseo-agent-settings-sheet-model";
|
|
||||||
|
|
||||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
|
||||||
return {
|
|
||||||
id: "catalog-alpha",
|
|
||||||
label: "Catalog Alpha",
|
|
||||||
api: "responses",
|
|
||||||
baseUrl: "https://alpha.example.test",
|
|
||||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerConfig(
|
|
||||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
|
||||||
): RedactedPaseoAgentProviderConfig {
|
|
||||||
return {
|
|
||||||
name: "catalog-alpha",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
models: [{ id: "alpha-fast" }],
|
|
||||||
available: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("paseo-agent-settings-sheet-model", () => {
|
|
||||||
it("recognizes supported catalog auth manifests", () => {
|
|
||||||
const apiKeyEntry = catalogEntry({
|
|
||||||
auth: {
|
|
||||||
kind: "api_key",
|
|
||||||
envVar: "ALPHA_API_KEY",
|
|
||||||
keyUrl: "https://alpha.example.test/key",
|
|
||||||
placeholder: "alpha-key",
|
|
||||||
hint: "Paste the key from Alpha",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const oauthEntry = catalogEntry({
|
|
||||||
auth: { kind: "oauth", flow: "alpha-oauth" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
|
|
||||||
kind: "api_key",
|
|
||||||
envVar: "ALPHA_API_KEY",
|
|
||||||
keyUrl: "https://alpha.example.test/key",
|
|
||||||
placeholder: "alpha-key",
|
|
||||||
hint: "Paste the key from Alpha",
|
|
||||||
});
|
|
||||||
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
|
|
||||||
kind: "oauth",
|
|
||||||
flow: "alpha-oauth",
|
|
||||||
});
|
|
||||||
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
|
|
||||||
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps unknown catalog auth kinds visible but unsupported", () => {
|
|
||||||
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
|
|
||||||
|
|
||||||
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
|
|
||||||
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
|
|
||||||
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers device-code OAuth only for a relay connection", () => {
|
|
||||||
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
|
|
||||||
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
|
|
||||||
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
|
|
||||||
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
|
|
||||||
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses model ids from comma and newline separated input", () => {
|
|
||||||
expect(
|
|
||||||
parsePaseoAgentModelIds(`
|
|
||||||
alpha/fast, beta/steady
|
|
||||||
alpha/fast
|
|
||||||
gamma/deep
|
|
||||||
`),
|
|
||||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the catalog label for the first provider instance", () => {
|
|
||||||
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
|
|
||||||
"Acme Cloud",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("numbers later provider instances from the catalog label", () => {
|
|
||||||
expect(
|
|
||||||
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
|
|
||||||
providerConfig({ name: "Acme Cloud" }),
|
|
||||||
providerConfig({ name: "Acme Cloud (1)" }),
|
|
||||||
]),
|
|
||||||
).toBe("Acme Cloud (2)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
|
||||||
expect(
|
|
||||||
createPaseoAgentProviderInput({
|
|
||||||
entry: catalogEntry({ id: "catalog-beta" }),
|
|
||||||
name: " beta-main ",
|
|
||||||
apiKey: " beta-secret ",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
name: "beta-main",
|
|
||||||
providerType: "catalog-beta",
|
|
||||||
options: {
|
|
||||||
apiKey: "beta-secret",
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a generic provider payload with custom model ids", () => {
|
|
||||||
expect(
|
|
||||||
createPaseoAgentProviderInput({
|
|
||||||
entry: catalogEntry({ models: [] }),
|
|
||||||
name: "alpha-main",
|
|
||||||
apiKey: "alpha-secret",
|
|
||||||
modelIds: ["alpha/fast", "beta/steady"],
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
name: "alpha-main",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
apiKey: "alpha-secret",
|
|
||||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits model overrides when the catalog has no defaults", () => {
|
|
||||||
expect(
|
|
||||||
createPaseoAgentProviderInput({
|
|
||||||
entry: catalogEntry({ models: [] }),
|
|
||||||
name: "alpha-main",
|
|
||||||
apiKey: "alpha-secret",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
name: "alpha-main",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
apiKey: "alpha-secret",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a generic provider payload with an env reference for an empty key", () => {
|
|
||||||
expect(
|
|
||||||
createPaseoAgentProviderInput({
|
|
||||||
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
|
|
||||||
name: "beta-main",
|
|
||||||
apiKey: " ",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
name: "beta-main",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
apiKey: "$BETA_API_KEY",
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds an oauth provider payload without key material", () => {
|
|
||||||
expect(
|
|
||||||
createPaseoAgentProviderInput({
|
|
||||||
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
|
|
||||||
name: "alpha-login",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
name: "alpha-login",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses catalog labels and generic auth badges for instance rows", () => {
|
|
||||||
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
|
|
||||||
expect(
|
|
||||||
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
|
|
||||||
).toBe("custom-alpha");
|
|
||||||
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
|
|
||||||
label: "Connected",
|
|
||||||
variant: "success",
|
|
||||||
});
|
|
||||||
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
|
|
||||||
label: "Needs attention",
|
|
||||||
variant: "error",
|
|
||||||
});
|
|
||||||
expect(paseoAgentAuthBadge(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import type {
|
|
||||||
PaseoAgentCatalogEntry,
|
|
||||||
RedactedPaseoAgentProviderConfig,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
|
||||||
|
|
||||||
export interface PaseoAgentApiKeyAuthManifest {
|
|
||||||
kind: "api_key";
|
|
||||||
envVar: string;
|
|
||||||
keyUrl?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaseoAgentOAuthAuthManifest {
|
|
||||||
kind: "oauth";
|
|
||||||
flow: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaseoAgentAuthBadge {
|
|
||||||
label: string;
|
|
||||||
variant: "success" | "error" | "muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaseoAgentOAuthMode = "browser" | "device_code";
|
|
||||||
|
|
||||||
export interface PaseoAgentOAuthConnectionSignal {
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preferredPaseoAgentOAuthMode(
|
|
||||||
activeConnection: PaseoAgentOAuthConnectionSignal | null,
|
|
||||||
): PaseoAgentOAuthMode {
|
|
||||||
return activeConnection?.type === "relay" ? "device_code" : "browser";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPaseoAgentApiKeyAuth(
|
|
||||||
entry: PaseoAgentCatalogEntry,
|
|
||||||
): PaseoAgentApiKeyAuthManifest | null {
|
|
||||||
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "api_key",
|
|
||||||
envVar: entry.auth.envVar,
|
|
||||||
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
|
|
||||||
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
|
|
||||||
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPaseoAgentOAuthAuth(
|
|
||||||
entry: PaseoAgentCatalogEntry,
|
|
||||||
): PaseoAgentOAuthAuthManifest | null {
|
|
||||||
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { kind: "oauth", flow: entry.auth.flow };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
|
|
||||||
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paseoAgentProviderLabel(
|
|
||||||
provider: RedactedPaseoAgentProviderConfig,
|
|
||||||
catalogEntry: PaseoAgentCatalogEntry | undefined,
|
|
||||||
): string {
|
|
||||||
return catalogEntry?.label ?? provider.providerType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paseoAgentAuthBadge(
|
|
||||||
auth: RedactedPaseoAgentProviderConfig["auth"],
|
|
||||||
): PaseoAgentAuthBadge | null {
|
|
||||||
if (!auth || auth.kind === "none") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return auth.configured
|
|
||||||
? { label: "Connected", variant: "success" }
|
|
||||||
: { label: "Needs attention", variant: "error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parsePaseoAgentModelIds(raw: string): string[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const part of raw.split(/[\n,]/)) {
|
|
||||||
const id = part.trim();
|
|
||||||
if (id.length > 0 && !seen.has(id)) {
|
|
||||||
seen.add(id);
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextPaseoAgentProviderName(
|
|
||||||
entry: PaseoAgentCatalogEntry,
|
|
||||||
providers: RedactedPaseoAgentProviderConfig[],
|
|
||||||
): string {
|
|
||||||
const baseName = entry.label.trim() || entry.id;
|
|
||||||
const existingNames = new Set(providers.map((provider) => provider.name));
|
|
||||||
if (!existingNames.has(baseName)) {
|
|
||||||
return baseName;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 1; ; index += 1) {
|
|
||||||
const candidate = `${baseName} (${index})`;
|
|
||||||
if (!existingNames.has(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPaseoAgentProviderInput(input: {
|
|
||||||
entry: PaseoAgentCatalogEntry;
|
|
||||||
name: string;
|
|
||||||
apiKey?: string;
|
|
||||||
modelIds?: string[];
|
|
||||||
}): PaseoAgentSetProviderInput {
|
|
||||||
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
|
|
||||||
const trimmedKey = input.apiKey?.trim() ?? "";
|
|
||||||
let apiKey: string | undefined;
|
|
||||||
if (apiKeyAuth) {
|
|
||||||
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
|
|
||||||
}
|
|
||||||
const models =
|
|
||||||
input.modelIds && input.modelIds.length > 0
|
|
||||||
? input.modelIds.map((id) => ({ id }))
|
|
||||||
: input.entry.models.map((model) => ({ ...model }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: input.name.trim(),
|
|
||||||
providerType: input.entry.id,
|
|
||||||
options: {
|
|
||||||
...(models.length > 0 ? { models } : {}),
|
|
||||||
...(apiKey ? { apiKey } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { type ReactNode } from "react";
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type {
|
|
||||||
PaseoAgentCatalogEntry,
|
|
||||||
RedactedPaseoAgentProviderConfig,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
import type {
|
|
||||||
PaseoAgentOAuthCompleteResult,
|
|
||||||
PaseoAgentOAuthStartResult,
|
|
||||||
PaseoAgentSetProviderInput,
|
|
||||||
} from "@/hooks/use-paseo-agent-providers";
|
|
||||||
|
|
||||||
interface PaseoAgentProvidersHookMock {
|
|
||||||
supported: boolean;
|
|
||||||
catalogSupported: boolean;
|
|
||||||
providers: RedactedPaseoAgentProviderConfig[];
|
|
||||||
catalog: PaseoAgentCatalogEntry[];
|
|
||||||
defaultModel: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isCatalogLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
catalogError: string | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
setProvider: (
|
|
||||||
input: PaseoAgentSetProviderInput,
|
|
||||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
|
||||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
|
||||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
|
|
||||||
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
|
|
||||||
current: {
|
|
||||||
supported: true,
|
|
||||||
catalogSupported: true,
|
|
||||||
providers: [] as RedactedPaseoAgentProviderConfig[],
|
|
||||||
catalog: [] as PaseoAgentCatalogEntry[],
|
|
||||||
defaultModel: null as string | null,
|
|
||||||
isLoading: false,
|
|
||||||
isCatalogLoading: false,
|
|
||||||
error: null as string | null,
|
|
||||||
catalogError: null as string | null,
|
|
||||||
refresh: vi.fn(async () => undefined),
|
|
||||||
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
|
|
||||||
startOAuth: vi.fn(
|
|
||||||
async (): Promise<PaseoAgentOAuthStartResult> => ({
|
|
||||||
requestId: "oauth-start",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
authorization: null,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
completeOAuth: vi.fn(
|
|
||||||
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
|
|
||||||
requestId: "oauth-complete",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
auth: { kind: "oauth", configured: true },
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
hookState: initialHookState,
|
|
||||||
hostRuntimeSnapshot: {
|
|
||||||
current: {
|
|
||||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
|
||||||
} as {
|
|
||||||
activeConnection: { type: string; endpoint: string; display: string } | null;
|
|
||||||
} | null,
|
|
||||||
},
|
|
||||||
openExternalUrls: [] as string[],
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
borderRadius: { md: 6, lg: 8 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400", medium: "500" },
|
|
||||||
opacity: { 50: 0.5 },
|
|
||||||
colors: {
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
border: "#444",
|
|
||||||
destructive: "#f00",
|
|
||||||
statusSuccess: "#0f0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
View: ({
|
|
||||||
children,
|
|
||||||
testID,
|
|
||||||
role,
|
|
||||||
accessibilityRole,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
testID?: string;
|
|
||||||
role?: string;
|
|
||||||
accessibilityRole?: string;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
"data-testid": testID,
|
|
||||||
role: role ?? accessibilityRole,
|
|
||||||
"aria-label": accessibilityLabel,
|
|
||||||
},
|
|
||||||
children,
|
|
||||||
),
|
|
||||||
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
|
|
||||||
React.createElement("span", { "data-testid": testID }, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
withUnistyles:
|
|
||||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
|
||||||
({
|
|
||||||
uniProps,
|
|
||||||
...rest
|
|
||||||
}: {
|
|
||||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
|
||||||
} & Record<string, unknown>) => {
|
|
||||||
const themed = uniProps ? uniProps(theme) : {};
|
|
||||||
return React.createElement(Component, { ...rest, ...themed });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const icon = (name: string) => {
|
|
||||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
|
||||||
Icon.displayName = name;
|
|
||||||
return Icon;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
Bot: icon("Bot"),
|
|
||||||
Plus: icon("Plus"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
|
||||||
AdaptiveModalSheet: ({
|
|
||||||
visible,
|
|
||||||
header,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
testID,
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
header?: { title: string; back?: { label?: string; onPress: () => void } };
|
|
||||||
children: ReactNode;
|
|
||||||
footer?: ReactNode;
|
|
||||||
testID?: string;
|
|
||||||
}) =>
|
|
||||||
visible ? (
|
|
||||||
<section data-testid={testID}>
|
|
||||||
<h1>{header?.title}</h1>
|
|
||||||
{header?.back ? (
|
|
||||||
<button type="button" onClick={header.back.onPress}>
|
|
||||||
{header.back.label ?? "Back"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
{footer ? <footer>{footer}</footer> : null}
|
|
||||||
</section>
|
|
||||||
) : null,
|
|
||||||
AdaptiveTextInput: ({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
accessibilityLabel,
|
|
||||||
testID,
|
|
||||||
placeholder,
|
|
||||||
secureTextEntry,
|
|
||||||
editable = true,
|
|
||||||
multiline,
|
|
||||||
}: {
|
|
||||||
value?: string;
|
|
||||||
onChangeText?: (value: string) => void;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
testID?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
secureTextEntry?: boolean;
|
|
||||||
editable?: boolean;
|
|
||||||
multiline?: boolean;
|
|
||||||
}) => {
|
|
||||||
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
|
|
||||||
onChangeText?.(event.currentTarget.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputProps = {
|
|
||||||
"aria-label": accessibilityLabel,
|
|
||||||
"data-testid": testID,
|
|
||||||
disabled: !editable,
|
|
||||||
onChange: handleChange,
|
|
||||||
placeholder,
|
|
||||||
value: value ?? "",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (multiline) {
|
|
||||||
return React.createElement("textarea", inputProps);
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.createElement("input", {
|
|
||||||
...inputProps,
|
|
||||||
type: secureTextEntry ? "password" : "text",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/provider-icons", () => ({
|
|
||||||
getProviderIcon: (provider: string) => () =>
|
|
||||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/provider-icon-name", () => ({
|
|
||||||
resolveProviderIconName: (provider: string) =>
|
|
||||||
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/external-link", () => ({
|
|
||||||
ExternalLink: ({
|
|
||||||
href,
|
|
||||||
label,
|
|
||||||
testID,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
testID?: string;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) => (
|
|
||||||
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/button", () => ({
|
|
||||||
Button: ({
|
|
||||||
children,
|
|
||||||
disabled,
|
|
||||||
onPress,
|
|
||||||
testID,
|
|
||||||
}: {
|
|
||||||
children?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onPress?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
}) => (
|
|
||||||
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/status-badge", () => ({
|
|
||||||
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
|
|
||||||
usePaseoAgentProviders: () => hookState.current,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: async (url: string) => {
|
|
||||||
openExternalUrls.push(url);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
|
|
||||||
|
|
||||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
|
||||||
return {
|
|
||||||
id: "catalog-alpha",
|
|
||||||
label: "Catalog Alpha",
|
|
||||||
iconName: "known-icon",
|
|
||||||
docsUrl: "https://alpha.example.test/docs",
|
|
||||||
api: "responses",
|
|
||||||
baseUrl: "https://alpha.example.test",
|
|
||||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerConfig(
|
|
||||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
|
||||||
): RedactedPaseoAgentProviderConfig {
|
|
||||||
return {
|
|
||||||
name: "catalog-alpha",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
models: [{ id: "alpha-fast" }],
|
|
||||||
auth: { kind: "api_key", configured: true },
|
|
||||||
available: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSheet() {
|
|
||||||
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetHookState() {
|
|
||||||
hookState.current.supported = true;
|
|
||||||
hookState.current.catalogSupported = true;
|
|
||||||
hookState.current.providers = [];
|
|
||||||
hookState.current.catalog = [];
|
|
||||||
hookState.current.defaultModel = null;
|
|
||||||
hookState.current.isLoading = false;
|
|
||||||
hookState.current.isCatalogLoading = false;
|
|
||||||
hookState.current.error = null;
|
|
||||||
hookState.current.catalogError = null;
|
|
||||||
hookState.current.refresh = vi.fn(async () => undefined);
|
|
||||||
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
|
|
||||||
hookState.current.startOAuth = vi.fn(async () => ({
|
|
||||||
requestId: "oauth-start",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
authorization: null,
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
hookState.current.completeOAuth = vi.fn(async () => ({
|
|
||||||
requestId: "oauth-complete",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
auth: { kind: "oauth", configured: true },
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
hostRuntimeSnapshot.current = {
|
|
||||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
|
||||||
};
|
|
||||||
openExternalUrls.splice(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PaseoAgentSettingsSheet", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetHookState();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the catalog feature-gate message exactly once", () => {
|
|
||||||
hookState.current.catalogSupported = false;
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
|
|
||||||
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
|
|
||||||
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
|
|
||||||
hookState.current.catalog = [
|
|
||||||
catalogEntry({ label: "Alpha Provider" }),
|
|
||||||
catalogEntry({
|
|
||||||
id: "catalog-future",
|
|
||||||
label: "Future Provider",
|
|
||||||
auth: { kind: "future_auth", prompt: "not yet" },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
|
|
||||||
expect(screen.getByText("Alpha Provider")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Future Provider")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
|
|
||||||
"https://alpha.example.test/docs",
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
|
|
||||||
.disabled,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits an api-key provider with an explicit key", async () => {
|
|
||||||
hookState.current.catalog = [catalogEntry({ models: [] })];
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
|
||||||
expect(screen.queryByLabelText("Provider name")).toBeNull();
|
|
||||||
expect(screen.queryByLabelText("Models")).toBeNull();
|
|
||||||
fireEvent.change(screen.getByLabelText("API key"), {
|
|
||||||
target: { value: " alpha-secret " },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
|
||||||
name: "Catalog Alpha",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
apiKey: "alpha-secret",
|
|
||||||
},
|
|
||||||
} satisfies PaseoAgentSetProviderInput);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits an empty api-key field as a host env reference", async () => {
|
|
||||||
hookState.current.catalog = [catalogEntry({})];
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
|
||||||
name: "Catalog Alpha",
|
|
||||||
providerType: "catalog-alpha",
|
|
||||||
options: {
|
|
||||||
apiKey: "$ALPHA_API_KEY",
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
|
||||||
},
|
|
||||||
} satisfies PaseoAgentSetProviderInput);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
|
|
||||||
hookState.current.catalog = [
|
|
||||||
catalogEntry({
|
|
||||||
id: "catalog-login",
|
|
||||||
label: "Catalog Login",
|
|
||||||
auth: { kind: "oauth", flow: "login-flow" },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
hookState.current.startOAuth = vi.fn(async () => ({
|
|
||||||
requestId: "oauth-start",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
authorization: {
|
|
||||||
kind: "auth_url",
|
|
||||||
url: "https://login.example.test/oauth/authorize?state=abc",
|
|
||||||
instructions: "Open the sign-in page",
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
|
|
||||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
|
|
||||||
});
|
|
||||||
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
|
|
||||||
"https://login.example.test/oauth/authorize?state=abc",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a device-code oauth authorization and completes it", async () => {
|
|
||||||
hookState.current.catalog = [
|
|
||||||
catalogEntry({
|
|
||||||
id: "catalog-login",
|
|
||||||
label: "Catalog Login",
|
|
||||||
auth: { kind: "oauth", flow: "login-flow" },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
hookState.current.startOAuth = vi.fn(async () => ({
|
|
||||||
requestId: "oauth-start",
|
|
||||||
success: true,
|
|
||||||
name: "catalog-login",
|
|
||||||
authorization: {
|
|
||||||
kind: "device_code",
|
|
||||||
userCode: "CODE-123",
|
|
||||||
verificationUri: "https://login.example.test/device",
|
|
||||||
intervalSeconds: 5,
|
|
||||||
expiresInSeconds: 600,
|
|
||||||
instructions: "Enter the code",
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
|
|
||||||
|
|
||||||
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
|
|
||||||
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
|
||||||
name: "Catalog Login",
|
|
||||||
providerType: "catalog-login",
|
|
||||||
options: {
|
|
||||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
|
||||||
},
|
|
||||||
} satisfies PaseoAgentSetProviderInput);
|
|
||||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
|
|
||||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers device-code first over relay while keeping browser available", () => {
|
|
||||||
hostRuntimeSnapshot.current = {
|
|
||||||
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
|
|
||||||
};
|
|
||||||
hookState.current.catalog = [
|
|
||||||
catalogEntry({
|
|
||||||
id: "catalog-login",
|
|
||||||
label: "Catalog Login",
|
|
||||||
auth: { kind: "oauth", flow: "login-flow" },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
|
||||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
|
||||||
|
|
||||||
const actionButtons = screen
|
|
||||||
.getByTestId("paseo-agent-oauth-sign-in")
|
|
||||||
.querySelectorAll("button");
|
|
||||||
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
|
|
||||||
"Providers",
|
|
||||||
"Cancel",
|
|
||||||
"Use a code instead",
|
|
||||||
"Sign in with browser",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows auth-state badges from configured instances", () => {
|
|
||||||
hookState.current.catalog = [
|
|
||||||
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
|
|
||||||
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
|
|
||||||
];
|
|
||||||
hookState.current.providers = [
|
|
||||||
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
|
|
||||||
providerConfig({
|
|
||||||
name: "beta",
|
|
||||||
providerType: "catalog-beta",
|
|
||||||
auth: { kind: "api_key", configured: false },
|
|
||||||
}),
|
|
||||||
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderSheet();
|
|
||||||
|
|
||||||
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
|
|
||||||
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Connected")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Needs attention")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,23 +13,11 @@ describe("resolveProviderIconName", () => {
|
|||||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||||
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
|
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
|
||||||
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves Paseo Agent model-provider catalog icon names", () => {
|
|
||||||
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
|
|
||||||
expect(resolveProviderIconName("openrouter")).toEqual({
|
|
||||||
kind: "builtin",
|
|
||||||
id: "openrouter",
|
|
||||||
});
|
|
||||||
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
|
|
||||||
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
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("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
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", () => {
|
it("falls back to the bot icon for unknown custom providers", () => {
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon";
|
|||||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||||
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
|
|
||||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|
||||||
import { PiIcon } from "@/components/icons/pi-icon";
|
import { PiIcon } from "@/components/icons/pi-icon";
|
||||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||||
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||||
@@ -27,10 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
|||||||
kiro: PackagePlus,
|
kiro: PackagePlus,
|
||||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||||
openai: CodexIcon as unknown as ProviderIconComponent,
|
|
||||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||||
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
|
|
||||||
paseo: PaseoLogo as unknown as ProviderIconComponent,
|
|
||||||
pi: PiIcon as unknown as ProviderIconComponent,
|
pi: PiIcon as unknown as ProviderIconComponent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
|
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
|
||||||
const PASEO_AGENT_PROVIDER = "paseo";
|
|
||||||
|
|
||||||
export function ProviderSettingsHost() {
|
export function ProviderSettingsHost() {
|
||||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||||
const provider = useProviderSettingsStore((state) => state.provider);
|
const provider = useProviderSettingsStore((state) => state.provider);
|
||||||
@@ -19,10 +16,6 @@ export function ProviderSettingsHost() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === PASEO_AGENT_PROVIDER) {
|
|
||||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProviderDiagnosticSheet
|
<ProviderDiagnosticSheet
|
||||||
key={`${serverId}:${provider}`}
|
key={`${serverId}:${provider}`}
|
||||||
|
|||||||
@@ -92,49 +92,30 @@ function QuestionOptionRow({
|
|||||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
|
const accessibilityState = useMemo(() => ({ selected: 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
accessibilityRole={multiSelect ? "checkbox" : "radio"}
|
accessibilityRole="button"
|
||||||
accessibilityLabel={option.label}
|
accessibilityLabel={option.label}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-checked={isSelected}
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
<View style={styles.optionItemContent}>
|
<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}>
|
<View style={styles.optionTextBlock}>
|
||||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
{isSelected ? (
|
||||||
|
<View style={styles.optionCheckSlot}>
|
||||||
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -143,9 +124,7 @@ function QuestionOptionRow({
|
|||||||
interface QuestionNavButtonProps {
|
interface QuestionNavButtonProps {
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
header: string;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isAnswered: boolean;
|
|
||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
}
|
}
|
||||||
@@ -153,9 +132,7 @@ interface QuestionNavButtonProps {
|
|||||||
function QuestionNavButton({
|
function QuestionNavButton({
|
||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
header,
|
|
||||||
isActive,
|
isActive,
|
||||||
isAnswered,
|
|
||||||
isResponding,
|
isResponding,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: QuestionNavButtonProps) {
|
}: QuestionNavButtonProps) {
|
||||||
@@ -194,7 +171,7 @@ function QuestionNavButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="tab"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
@@ -203,61 +180,11 @@ function QuestionNavButton({
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
>
|
>
|
||||||
{isAnswered ? (
|
<Text style={textStyle}>{index + 1}</Text>
|
||||||
<Check
|
|
||||||
size={12}
|
|
||||||
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text style={textStyle} numberOfLines={1}>
|
|
||||||
{header}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</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 {
|
interface QuestionOtherInputProps {
|
||||||
qIndex: number;
|
qIndex: number;
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
@@ -428,12 +355,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
setActiveQuestionIndex(index);
|
setActiveQuestionIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navIsAnswered = useCallback(
|
|
||||||
(qIndex: number) =>
|
|
||||||
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
|
|
||||||
[questions, selections, otherTexts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePrimaryAction = useCallback(() => {
|
const handlePrimaryAction = useCallback(() => {
|
||||||
if (!isLastQuestion) {
|
if (!isLastQuestion) {
|
||||||
if (!activeQuestionAnswered || isResponding) return;
|
if (!activeQuestionAnswered || isResponding) return;
|
||||||
@@ -486,16 +407,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||||
[theme.colors.foreground],
|
[theme.colors.foreground],
|
||||||
);
|
);
|
||||||
// Single-select radios need a group; checkboxes are valid standalone.
|
const questionNavStyle = useMemo(
|
||||||
const optionsGroupAccessibility = useMemo(
|
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||||
() =>
|
[isMobile],
|
||||||
activeQuestion && !activeQuestion.multiSelect
|
|
||||||
? ({
|
|
||||||
accessibilityRole: "radiogroup",
|
|
||||||
accessibilityLabel: activeQuestion.question,
|
|
||||||
} as const)
|
|
||||||
: {},
|
|
||||||
[activeQuestion],
|
|
||||||
);
|
);
|
||||||
const actionsContainerStyle = useMemo(
|
const actionsContainerStyle = useMemo(
|
||||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||||
@@ -522,23 +436,33 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} testID="question-form-card">
|
<View style={containerStyle} testID="question-form-card">
|
||||||
<QuestionNav
|
<View style={styles.questionTopRow}>
|
||||||
questions={questions}
|
<View style={styles.questionHeader}>
|
||||||
activeIndex={resolvedActiveQuestionIndex}
|
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||||
isAnswered={navIsAnswered}
|
{activeQuestion?.question}
|
||||||
isResponding={isResponding}
|
</Text>
|
||||||
onSelect={handleSelectQuestion}
|
</View>
|
||||||
/>
|
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||||
<View style={styles.questionHeader}>
|
{questions.map((question, qIndex) => {
|
||||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||||
{activeQuestion?.question}
|
return (
|
||||||
</Text>
|
<QuestionNavButton
|
||||||
|
key={question.header}
|
||||||
|
index={qIndex}
|
||||||
|
total={questions.length}
|
||||||
|
isActive={isActive}
|
||||||
|
isResponding={isResponding}
|
||||||
|
onSelect={handleSelectQuestion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{activeQuestion ? (
|
{activeQuestion ? (
|
||||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||||
{activeQuestion.options.length > 0 ? (
|
{activeQuestion.options.length > 0 ? (
|
||||||
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
|
<View style={styles.optionsWrap}>
|
||||||
{activeQuestion.options.map((opt, optIndex) => (
|
{activeQuestion.options.map((opt, optIndex) => (
|
||||||
<QuestionOptionRow
|
<QuestionOptionRow
|
||||||
key={opt.label}
|
key={opt.label}
|
||||||
@@ -622,6 +546,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
questionBlock: {
|
questionBlock: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
questionTopRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
questionHeader: {
|
questionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -641,24 +571,24 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
questionNav: {
|
questionNav: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[3],
|
},
|
||||||
|
questionNavMobile: {
|
||||||
|
paddingRight: theme.spacing[1],
|
||||||
},
|
},
|
||||||
questionNavButton: {
|
questionNavButton: {
|
||||||
flexDirection: "row",
|
minWidth: 28,
|
||||||
|
height: 28,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
justifyContent: "center",
|
||||||
minHeight: 28,
|
borderRadius: 999,
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
},
|
},
|
||||||
questionNavText: {
|
questionNavText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
optionItem: {
|
optionItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -673,40 +603,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
optionItemContent: {
|
optionItemContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-start",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
optionTextBlock: {
|
optionTextBlock: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: theme.spacing[1],
|
gap: 2,
|
||||||
},
|
},
|
||||||
optionLabel: {
|
optionLabel: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
},
|
||||||
optionDescription: {
|
optionDescription: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
lineHeight: 20,
|
lineHeight: 16,
|
||||||
},
|
},
|
||||||
selectionControl: {
|
optionCheckSlot: {
|
||||||
width: 18,
|
width: 16,
|
||||||
height: 18,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
borderWidth: theme.borderWidth[1],
|
marginLeft: "auto",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
otherInput: {
|
otherInput: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@@ -1,375 +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 { isWeb } from "@/constants/platform";
|
|
||||||
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}
|
|
||||||
style={styles.unitControl}
|
|
||||||
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 MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
// Both cadence segmented controls hug their options and stand at the form's
|
|
||||||
// field height, so the interval row reads as input + toggle rather than a
|
|
||||||
// full-width track with the controls floating inside it.
|
|
||||||
modeControl: {
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
height: 44,
|
|
||||||
},
|
|
||||||
unitControl: {
|
|
||||||
height: 44,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
chipSelected: {
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
borderColor: theme.colors.accent,
|
|
||||||
},
|
|
||||||
chipLabel: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
chipLabelSelected: {
|
|
||||||
color: theme.colors.accentForeground,
|
|
||||||
},
|
|
||||||
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: MONOSPACE_FONT,
|
|
||||||
},
|
|
||||||
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.destructive,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,867 +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 { useProjects } from "@/hooks/use-projects";
|
|
||||||
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 };
|
|
||||||
const PROJECT_OPTION_PREFIX = "project:";
|
|
||||||
|
|
||||||
export interface ScheduleFormSheetProps {
|
|
||||||
serverId?: string;
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
mode: "create" | "edit";
|
|
||||||
schedule?: ScheduleSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleProjectTarget {
|
|
||||||
optionId: string;
|
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 buildProjectOptionId(serverId: string, projectKey: string): string {
|
|
||||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: ScheduleProjectTarget[] = [];
|
|
||||||
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
|
|
||||||
const options: ComboboxOption[] = [];
|
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
for (const host of project.hosts) {
|
|
||||||
const cwd = host.repoRoot.trim();
|
|
||||||
if (!host.isOnline || !cwd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const target: ScheduleProjectTarget = {
|
|
||||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
cwd,
|
|
||||||
};
|
|
||||||
targets.push(target);
|
|
||||||
targetByOptionId.set(target.optionId, target);
|
|
||||||
options.push({
|
|
||||||
id: target.optionId,
|
|
||||||
label: target.projectName,
|
|
||||||
description: `${target.serverName} - ${shortenPath(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScheduleFormSheet({
|
|
||||||
serverId,
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
mode,
|
|
||||||
schedule,
|
|
||||||
}: ScheduleFormSheetProps): ReactElement {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const editConfig = newAgentConfig(schedule);
|
|
||||||
const { projects } = useProjects();
|
|
||||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
setSelectedServerIdFromUser,
|
|
||||||
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) => {
|
|
||||||
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
|
|
||||||
clearProviderSelectionFromUser();
|
|
||||||
}
|
|
||||||
setSelectedServerIdFromUser(target.serverId);
|
|
||||||
setWorkingDirFromUser(target.cwd);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
clearProviderSelectionFromUser,
|
|
||||||
selectedProjectTarget,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
wasVisibleRef.current = visible;
|
|
||||||
}, [visible, schedule]);
|
|
||||||
|
|
||||||
const promptTrimmed = prompt.trim();
|
|
||||||
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
|
||||||
const selectedModelIsValid = isSelectedModelValidForProviders({
|
|
||||||
providers: modelSelectorProviders,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
});
|
|
||||||
const canSubmit =
|
|
||||||
promptTrimmed.length > 0 &&
|
|
||||||
selectedModelIsValid &&
|
|
||||||
Boolean(selectedProjectTarget) &&
|
|
||||||
cadenceError === null &&
|
|
||||||
!isSubmitting;
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
await persistFormPreferences();
|
|
||||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
|
||||||
const maxRunsValue =
|
|
||||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
|
||||||
|
|
||||||
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: selectedProjectTarget.cwd,
|
|
||||||
},
|
|
||||||
maxRuns: maxRunsValue,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await createSchedule({
|
|
||||||
prompt: promptTrimmed,
|
|
||||||
name: name.trim() || undefined,
|
|
||||||
cadence,
|
|
||||||
target: {
|
|
||||||
type: "new-agent",
|
|
||||||
config: {
|
|
||||||
provider: selectedProvider,
|
|
||||||
cwd: selectedProjectTarget.cwd,
|
|
||||||
model: selectedModel || undefined,
|
|
||||||
modeId: selectedMode || undefined,
|
|
||||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
|
||||||
title: name.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
setSubmitError(toErrorMessage(error));
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
cadence,
|
|
||||||
createSchedule,
|
|
||||||
isEdit,
|
|
||||||
maxRuns,
|
|
||||||
name,
|
|
||||||
onClose,
|
|
||||||
persistFormPreferences,
|
|
||||||
promptTrimmed,
|
|
||||||
schedule,
|
|
||||||
selectedMode,
|
|
||||||
selectedModel,
|
|
||||||
selectedProjectTarget,
|
|
||||||
selectedProvider,
|
|
||||||
selectedThinkingOptionId,
|
|
||||||
updateSchedule,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<View style={styles.field}>
|
|
||||||
<Text style={styles.label}>Project</Text>
|
|
||||||
<ProjectField
|
|
||||||
options={projectOptions.options}
|
|
||||||
targetByOptionId={projectOptions.targetByOptionId}
|
|
||||||
value={selectedProjectOptionId}
|
|
||||||
selectedTarget={selectedProjectTarget}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{editConfig === null && isEdit ? (
|
|
||||||
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
options: ComboboxOption[];
|
|
||||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
|
||||||
value: string;
|
|
||||||
selectedTarget: ScheduleProjectTarget | null;
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayValue = selectedTarget?.projectName ?? "Select project";
|
|
||||||
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={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
|
||||||
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.destructive,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
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,339 +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 { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
|
||||||
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;
|
|
||||||
pending?: ScheduleRowPending;
|
|
||||||
isFirst: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
|
||||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelLabel(schedule: ScheduleSummary): string {
|
|
||||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
|
||||||
return schedule.target.config.model;
|
|
||||||
}
|
|
||||||
return "Default model";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
|
|
||||||
return status === "active" ? "success" : "muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: ScheduleSummary["status"]): string {
|
|
||||||
if (status === "active") {
|
|
||||||
return "Active";
|
|
||||||
}
|
|
||||||
if (status === "paused") {
|
|
||||||
return "Paused";
|
|
||||||
}
|
|
||||||
return "Completed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRunLabel(schedule: ScheduleSummary): string {
|
|
||||||
if (schedule.status === "paused") {
|
|
||||||
return "Paused";
|
|
||||||
}
|
|
||||||
if (schedule.status === "completed") {
|
|
||||||
return "Completed";
|
|
||||||
}
|
|
||||||
return formatNextRun(schedule.nextRunAt) || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
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 provider = resolveProvider(schedule);
|
|
||||||
const title = resolveScheduleTitle(schedule);
|
|
||||||
const meta = [
|
|
||||||
resolveModelLabel(schedule),
|
|
||||||
formatCadence(schedule.cadence),
|
|
||||||
nextRunLabel(schedule),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ");
|
|
||||||
|
|
||||||
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={settingsStyles.rowHint} numberOfLines={1}>
|
|
||||||
{meta}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.trailing}>
|
|
||||||
<StatusBadge
|
|
||||||
label={statusLabel(schedule.status)}
|
|
||||||
variant={statusVariant(schedule.status)}
|
|
||||||
/>
|
|
||||||
<ScheduleKebabMenu
|
|
||||||
schedule={schedule}
|
|
||||||
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,
|
|
||||||
pending,
|
|
||||||
onEdit,
|
|
||||||
onPause,
|
|
||||||
onResume,
|
|
||||||
onRunNow,
|
|
||||||
onDelete,
|
|
||||||
}: Omit<ScheduleRowProps, "isFirst">): 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}
|
|
||||||
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"}
|
|
||||||
status={pending?.pause ? "pending" : "idle"}
|
|
||||||
pendingLabel="Pausing..."
|
|
||||||
onSelect={onPause}
|
|
||||||
testID={`schedule-menu-pause-${schedule.id}`}
|
|
||||||
>
|
|
||||||
Pause schedule
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
leading={runLeading}
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
trailing: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
kebabTrigger: {
|
|
||||||
padding: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.base,
|
|
||||||
},
|
|
||||||
kebabTriggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,151 +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 { settingsStyles } from "@/styles/settings";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
||||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
|
|
||||||
interface SchedulesTableProps {
|
|
||||||
serverId: string;
|
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
/**
|
|
||||||
* The form sheet is owned by the screen (it serves both create and edit and
|
|
||||||
* shares the header's "New schedule" button), so the table delegates edit
|
|
||||||
* upward rather than mounting a second sheet here.
|
|
||||||
*/
|
|
||||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The schedules list: a single settings-style card of rows in a centered,
|
|
||||||
* width-constrained reading column, matching the projects list. Owns row-level
|
|
||||||
* actions (pause/resume/run/delete via the mutations hook + a destructive
|
|
||||||
* confirm for delete) and delegates editing to the parent.
|
|
||||||
*/
|
|
||||||
export function SchedulesTable({
|
|
||||||
serverId,
|
|
||||||
schedules,
|
|
||||||
onEditSchedule,
|
|
||||||
}: SchedulesTableProps): ReactElement {
|
|
||||||
const mutations = useScheduleMutations({ serverId });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.listContent} testID="schedules-table">
|
|
||||||
<View style={settingsStyles.card}>
|
|
||||||
{schedules.map((schedule, index) => (
|
|
||||||
<SchedulesTableRow
|
|
||||||
key={schedule.id}
|
|
||||||
schedule={schedule}
|
|
||||||
isFirst={index === 0}
|
|
||||||
mutations={mutations}
|
|
||||||
onEditSchedule={onEditSchedule}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-row wrapper owns local in-flight state and binds the table's mutation
|
|
||||||
// callbacks to this schedule. 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).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
|
||||||
|
|
||||||
const NO_PENDING: ScheduleRowPending = {};
|
|
||||||
|
|
||||||
function SchedulesTableRow({
|
|
||||||
schedule,
|
|
||||||
isFirst,
|
|
||||||
mutations,
|
|
||||||
onEditSchedule,
|
|
||||||
}: {
|
|
||||||
schedule: ScheduleSummary;
|
|
||||||
isFirst: boolean;
|
|
||||||
mutations: ScheduleMutations;
|
|
||||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const { id } = schedule;
|
|
||||||
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}
|
|
||||||
isFirst={isFirst}
|
|
||||||
pending={pending}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onPause={handlePause}
|
|
||||||
onResume={handleResume}
|
|
||||||
onRunNow={handleRunNow}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTENT_MAX_WIDTH = 720;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
// Center the card in a readable column, matching settings and projects.
|
|
||||||
listContent: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: CONTENT_MAX_WIDTH,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1749,13 +1749,14 @@ function WorkspaceRowItem({
|
|||||||
isDragging = false,
|
isDragging = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: WorkspaceRowItemProps) {
|
}: WorkspaceRowItemProps) {
|
||||||
|
const currentPathname = usePathname();
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (!workspace.serverId) {
|
if (!workspace.serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useCallback, useMemo, useState } from "react";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
@@ -333,6 +334,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
}) {
|
}) {
|
||||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const currentPathname = usePathname();
|
||||||
const selected =
|
const selected =
|
||||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||||
@@ -340,8 +342,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (!workspace.serverId) return;
|
if (!workspace.serverId) return;
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
if (!workspaceEntry) return null;
|
if (!workspaceEntry) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWorkspaceAttachment(attachment)) {
|
if (isWorkspaceAttachment(attachment)) {
|
||||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
|
||||||
images.push(attachment.attachment.screenshot);
|
|
||||||
}
|
|
||||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||||
if (workspaceAttachment) {
|
if (workspaceAttachment) {
|
||||||
agentAttachments.push(workspaceAttachment);
|
agentAttachments.push(workspaceAttachment);
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export function getAttachmentKey(attachment: WorkspaceComposerAttachment): strin
|
|||||||
tag: attachment.attachment.tag,
|
tag: attachment.attachment.tag,
|
||||||
text: attachment.attachment.text,
|
text: attachment.attachment.text,
|
||||||
html: attachment.attachment.outerHTML,
|
html: attachment.attachment.outerHTML,
|
||||||
intent: attachment.attachment.intent ?? null,
|
|
||||||
comment: attachment.attachment.comment ?? null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
|
|||||||
@@ -335,15 +335,6 @@ const CATALOG_DATA = [
|
|||||||
installLink: "https://stakpak.dev/",
|
installLink: "https://stakpak.dev/",
|
||||||
command: ["stakpak", "acp"],
|
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",
|
id: "vtcode",
|
||||||
title: "VT Code",
|
title: "VT Code",
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getElectronHost } from "@/desktop/electron/host";
|
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";
|
export type DesktopNotificationPermission = "granted" | "denied" | "default";
|
||||||
|
|
||||||
@@ -127,24 +117,9 @@ export interface DesktopBrowserNewTabRequestEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopBrowserBridge {
|
export interface DesktopBrowserBridge {
|
||||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
|
||||||
setWorkspaceActiveBrowser?: (input: {
|
|
||||||
workspaceId: string;
|
|
||||||
browserId: string | null;
|
|
||||||
}) => Promise<void>;
|
|
||||||
setAgentActiveBrowser?: (input: { agentId: string; browserId: string | null }) => Promise<void>;
|
|
||||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||||
clearPartition?: (browserId: string) => Promise<void>;
|
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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopInvokeBridge {
|
export interface DesktopInvokeBridge {
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe("ACP provider catalog", () => {
|
|||||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||||
expect(findProvider("poolside").command).toEqual(["pool", "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", () => {
|
it("maps a catalog entry to the daemon provider config patch", () => {
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export interface UseAgentFormStateResult {
|
|||||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||||
refetchProviderModelsIfStale: () => void;
|
refetchProviderModelsIfStale: () => void;
|
||||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||||
clearProviderSelectionFromUser: () => void;
|
|
||||||
workingDirIsEmpty: boolean;
|
workingDirIsEmpty: boolean;
|
||||||
persistFormPreferences: () => Promise<void>;
|
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(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
@@ -552,7 +547,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
refetchProviderModelsIfStale,
|
refetchProviderModelsIfStale,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
clearProviderSelectionFromUser,
|
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
}),
|
}),
|
||||||
@@ -587,7 +581,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
refetchProviderModelsIfStale,
|
refetchProviderModelsIfStale,
|
||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
clearProviderSelectionFromUser,
|
|
||||||
workingDirIsEmpty,
|
workingDirIsEmpty,
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { TextInput } from "react-native";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
@@ -136,6 +136,7 @@ function resolveActionShortcutKeys(
|
|||||||
|
|
||||||
export function useCommandCenter() {
|
export function useCommandCenter() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const pathname = usePathname();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||||
@@ -222,9 +223,10 @@ export function useCommandCenter() {
|
|||||||
navigateToAgent({
|
navigateToAgent({
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setOpen],
|
[pathname, setOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openProjectPicker = useOpenProjectPicker();
|
const openProjectPicker = useOpenProjectPicker();
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
|
|||||||
serverId: action.serverId,
|
serverId: action.serverId,
|
||||||
workspaceId: action.workspaceId,
|
workspaceId: action.workspaceId,
|
||||||
};
|
};
|
||||||
navigateToWorkspace(action.serverId, action.workspaceId);
|
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
case "navigate-last-workspace":
|
case "navigate-last-workspace":
|
||||||
return navigateToLastWorkspace();
|
return navigateToLastWorkspace();
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type {
|
|
||||||
PaseoAgentCatalogEntry,
|
|
||||||
PaseoAgentOAuthCompleteResponse,
|
|
||||||
PaseoAgentOAuthStartResponse,
|
|
||||||
PaseoAgentRenameProviderRequest,
|
|
||||||
PaseoAgentSetProviderRequest,
|
|
||||||
RedactedPaseoAgentProviderConfig,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
|
|
||||||
export function paseoAgentProvidersQueryKey(serverId: string | null) {
|
|
||||||
return ["paseo-agent-providers", serverId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paseoAgentCatalogQueryKey(serverId: string | null) {
|
|
||||||
return ["paseo-agent-catalog", serverId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeQueryError(error: unknown): string | null {
|
|
||||||
if (!error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
|
||||||
export type PaseoAgentRenameProviderInput = Omit<
|
|
||||||
PaseoAgentRenameProviderRequest,
|
|
||||||
"type" | "requestId"
|
|
||||||
>;
|
|
||||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
|
||||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
|
||||||
|
|
||||||
interface UsePaseoAgentProvidersResult {
|
|
||||||
supported: boolean;
|
|
||||||
catalogSupported: boolean;
|
|
||||||
providers: RedactedPaseoAgentProviderConfig[];
|
|
||||||
catalog: PaseoAgentCatalogEntry[];
|
|
||||||
defaultModel: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isCatalogLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
catalogError: string | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
setProvider: (
|
|
||||||
input: PaseoAgentSetProviderInput,
|
|
||||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
|
||||||
renameProvider: (
|
|
||||||
input: PaseoAgentRenameProviderInput,
|
|
||||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
|
||||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
|
||||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
|
||||||
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
|
|
||||||
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
|
|
||||||
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
|
|
||||||
const supported = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
|
|
||||||
);
|
|
||||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
|
|
||||||
const catalogSupported = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
|
|
||||||
);
|
|
||||||
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
|
|
||||||
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey,
|
|
||||||
enabled: Boolean(supported && serverId && client && isConnected),
|
|
||||||
staleTime: 30_000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
return client.getPaseoAgentProviders();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogQuery = useQuery({
|
|
||||||
queryKey: catalogQueryKey,
|
|
||||||
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
|
|
||||||
staleTime: 30_000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
return client.getPaseoAgentCatalog();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
|
|
||||||
}, [catalogQueryKey, queryClient, queryKey]);
|
|
||||||
|
|
||||||
const error = query.data?.error ?? describeQueryError(query.error);
|
|
||||||
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
|
|
||||||
|
|
||||||
const setProviderMutation = useMutation({
|
|
||||||
mutationFn: async (input: PaseoAgentSetProviderInput) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
const result = await client.setPaseoAgentProvider(input);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
|
||||||
}
|
|
||||||
return result.provider;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { mutateAsync: setProviderAsync } = setProviderMutation;
|
|
||||||
|
|
||||||
const setProvider = useCallback(
|
|
||||||
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
|
|
||||||
[setProviderAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renameProviderMutation = useMutation({
|
|
||||||
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
const result = await client.renamePaseoAgentProvider(input);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
|
||||||
}
|
|
||||||
return result.provider;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
|
|
||||||
|
|
||||||
const renameProvider = useCallback(
|
|
||||||
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
|
|
||||||
[renameProviderAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
const startOAuthMutation = useMutation({
|
|
||||||
mutationFn: async (input: { name: string; mode?: string }) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
|
|
||||||
|
|
||||||
const startOAuth = useCallback(
|
|
||||||
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
|
|
||||||
[startOAuthAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
const completeOAuthMutation = useMutation({
|
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error(hostDisconnectedMessage);
|
|
||||||
}
|
|
||||||
const result = await client.completePaseoAgentOAuth(name);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
|
|
||||||
|
|
||||||
const completeOAuth = useCallback(
|
|
||||||
(name: string) => completeOAuthAsync(name),
|
|
||||||
[completeOAuthAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
supported,
|
|
||||||
catalogSupported,
|
|
||||||
providers: query.data?.providers ?? [],
|
|
||||||
catalog: catalogQuery.data?.catalog ?? [],
|
|
||||||
defaultModel: query.data?.defaultModel ?? null,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
isCatalogLoading: catalogQuery.isLoading,
|
|
||||||
error,
|
|
||||||
catalogError,
|
|
||||||
refresh,
|
|
||||||
setProvider,
|
|
||||||
renameProvider,
|
|
||||||
startOAuth,
|
|
||||||
completeOAuth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import type { ProjectSummary } from "@/utils/projects";
|
import type { ProjectSummary } from "@/utils/projects";
|
||||||
@@ -16,10 +16,6 @@ export type {
|
|||||||
|
|
||||||
export const projectsQueryKey = ["projects"] as const;
|
export const projectsQueryKey = ["projects"] as const;
|
||||||
|
|
||||||
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
|
|
||||||
return hosts.map((host) => host.serverId).join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
hostErrors: ProjectHostError[];
|
hostErrors: ProjectHostError[];
|
||||||
@@ -31,11 +27,6 @@ export interface UseProjectsResult {
|
|||||||
export function useProjects(): UseProjectsResult {
|
export function useProjects(): UseProjectsResult {
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const runtimeVersion = useSyncExternalStore(
|
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
|
||||||
() => runtime.getVersion(),
|
|
||||||
() => runtime.getVersion(),
|
|
||||||
);
|
|
||||||
const hostInputs = useMemo<ProjectsHostInput[]>(
|
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||||
() =>
|
() =>
|
||||||
hosts.map((host) => ({
|
hosts.map((host) => ({
|
||||||
@@ -46,9 +37,8 @@ export function useProjects(): UseProjectsResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const projectsQuery = useQuery({
|
const projectsQuery = useQuery({
|
||||||
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
|
queryKey: projectsQueryKey,
|
||||||
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||||
staleTime: 5_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,277 +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 { 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 updateScheduleSections(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
updateSection: (
|
|
||||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
): void {
|
|
||||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
|
||||||
{ queryKey: schedulesQueryBaseKey },
|
|
||||||
(current) => {
|
|
||||||
if (!current) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return { sections: current.sections.map(updateSection) };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticallySetStatus(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
serverId: string,
|
|
||||||
id: string,
|
|
||||||
status: ScheduleSummary["status"],
|
|
||||||
): void {
|
|
||||||
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
|
||||||
updateScheduleSections(queryClient, (section) =>
|
|
||||||
section.serverId === serverId
|
|
||||||
? {
|
|
||||||
...section,
|
|
||||||
schedules: section.schedules.map((schedule) =>
|
|
||||||
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
|
||||||
updateScheduleSections(queryClient, (section) =>
|
|
||||||
section.serverId === serverId
|
|
||||||
? {
|
|
||||||
...section,
|
|
||||||
schedules: section.schedules.filter((schedule) => schedule.id !== id),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,60 +0,0 @@
|
|||||||
import { useMemo, useSyncExternalStore } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
|
||||||
import {
|
|
||||||
fetchAggregatedSchedules,
|
|
||||||
type ScheduleHostInput,
|
|
||||||
type ScheduleHostSection,
|
|
||||||
} from "@/schedules/aggregated-schedules";
|
|
||||||
|
|
||||||
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
|
|
||||||
|
|
||||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
|
||||||
|
|
||||||
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
|
|
||||||
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSchedulesResult {
|
|
||||||
sections: ScheduleHostSection[];
|
|
||||||
isLoading: 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), runtimeVersion] as const,
|
|
||||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
|
||||||
staleTime: 5_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sections: query.data?.sections ?? [],
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
isError: query.isError,
|
|
||||||
error: query.error,
|
|
||||||
refetch: () => {
|
|
||||||
void query.refetch();
|
|
||||||
},
|
|
||||||
isRefetching: query.isRefetching,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -423,25 +423,6 @@ export const ar: TranslationResources = {
|
|||||||
openDevTools: "افتح أدوات تطوير المتصفح",
|
openDevTools: "افتح أدوات تطوير المتصفح",
|
||||||
cancelSelector: "إلغاء محدد العنصر",
|
cancelSelector: "إلغاء محدد العنصر",
|
||||||
selectElement: "حدد العنصر",
|
selectElement: "حدد العنصر",
|
||||||
grabElement: "نسخ العنصر إلى الحافظة",
|
|
||||||
grabElementLabel: "العنصر",
|
|
||||||
grabFailed: "تعذّر نسخ العنصر",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "إرسال ملاحظات إلى الوكيل",
|
|
||||||
placeholder: "صف ما الذي ينبغي تغييره…",
|
|
||||||
submit: "إرفاق",
|
|
||||||
cancel: "إلغاء",
|
|
||||||
intents: {
|
|
||||||
fix: "إصلاح",
|
|
||||||
change: "تغيير",
|
|
||||||
question: "سؤال",
|
|
||||||
approve: "موافقة",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "حجم الجهاز",
|
|
||||||
responsive: "متجاوب",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "فشل تحميل الصفحة",
|
failedToLoad: "فشل تحميل الصفحة",
|
||||||
@@ -789,7 +770,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "السجل",
|
sessions: "السجل",
|
||||||
schedules: "الجداول",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "إعداد البرامج النصية لشجرة العمل",
|
title: "إعداد البرامج النصية لشجرة العمل",
|
||||||
|
|||||||
@@ -423,25 +423,6 @@ export const en = {
|
|||||||
openDevTools: "Open browser dev tools",
|
openDevTools: "Open browser dev tools",
|
||||||
cancelSelector: "Cancel element selector",
|
cancelSelector: "Cancel element selector",
|
||||||
selectElement: "Select element",
|
selectElement: "Select element",
|
||||||
grabElement: "Copy element to clipboard",
|
|
||||||
grabElementLabel: "element",
|
|
||||||
grabFailed: "Couldn't copy element",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "Send feedback to agent",
|
|
||||||
placeholder: "Describe what should change…",
|
|
||||||
submit: "Attach",
|
|
||||||
cancel: "Cancel",
|
|
||||||
intents: {
|
|
||||||
fix: "Fix",
|
|
||||||
change: "Change",
|
|
||||||
question: "Question",
|
|
||||||
approve: "Approve",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "Device size",
|
|
||||||
responsive: "Responsive",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "Failed to load page",
|
failedToLoad: "Failed to load page",
|
||||||
@@ -796,7 +777,6 @@ export const en = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "History",
|
sessions: "History",
|
||||||
schedules: "Schedules",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Set up worktree scripts",
|
title: "Set up worktree scripts",
|
||||||
|
|||||||
@@ -427,25 +427,6 @@ export const es: TranslationResources = {
|
|||||||
openDevTools: "Abrir herramientas de desarrollo del navegador",
|
openDevTools: "Abrir herramientas de desarrollo del navegador",
|
||||||
cancelSelector: "Cancelar selector de elementos",
|
cancelSelector: "Cancelar selector de elementos",
|
||||||
selectElement: "Seleccionar elemento",
|
selectElement: "Seleccionar elemento",
|
||||||
grabElement: "Copiar elemento al portapapeles",
|
|
||||||
grabElementLabel: "elemento",
|
|
||||||
grabFailed: "No se pudo copiar el elemento",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "Enviar comentarios al agente",
|
|
||||||
placeholder: "Describe qué debería cambiar…",
|
|
||||||
submit: "Adjuntar",
|
|
||||||
cancel: "Cancelar",
|
|
||||||
intents: {
|
|
||||||
fix: "Corregir",
|
|
||||||
change: "Cambiar",
|
|
||||||
question: "Pregunta",
|
|
||||||
approve: "Aprobar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "Tamaño del dispositivo",
|
|
||||||
responsive: "Adaptable",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "No se pudo cargar la página",
|
failedToLoad: "No se pudo cargar la página",
|
||||||
@@ -816,7 +797,6 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Historial",
|
sessions: "Historial",
|
||||||
schedules: "Horarios",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurar secuencias de comandos del árbol de trabajo",
|
title: "Configurar secuencias de comandos del árbol de trabajo",
|
||||||
|
|||||||
@@ -427,25 +427,6 @@ export const fr: TranslationResources = {
|
|||||||
openDevTools: "Outils de développement du navigateur ouvert",
|
openDevTools: "Outils de développement du navigateur ouvert",
|
||||||
cancelSelector: "Annuler le sélecteur d'élément",
|
cancelSelector: "Annuler le sélecteur d'élément",
|
||||||
selectElement: "Sélectionner un élément",
|
selectElement: "Sélectionner un élément",
|
||||||
grabElement: "Copier l'élément dans le presse-papiers",
|
|
||||||
grabElementLabel: "élément",
|
|
||||||
grabFailed: "Impossible de copier l'élément",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "Envoyer un retour à l'agent",
|
|
||||||
placeholder: "Décrivez ce qui doit changer…",
|
|
||||||
submit: "Joindre",
|
|
||||||
cancel: "Annuler",
|
|
||||||
intents: {
|
|
||||||
fix: "Corriger",
|
|
||||||
change: "Modifier",
|
|
||||||
question: "Question",
|
|
||||||
approve: "Approuver",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "Taille de l'appareil",
|
|
||||||
responsive: "Adaptatif",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "Échec du chargement de la page",
|
failedToLoad: "Échec du chargement de la page",
|
||||||
@@ -815,7 +796,6 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Historique",
|
sessions: "Historique",
|
||||||
schedules: "Planifications",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurer les scripts d'arbre de travail",
|
title: "Configurer les scripts d'arbre de travail",
|
||||||
|
|||||||
@@ -427,25 +427,6 @@ export const ja: TranslationResources = {
|
|||||||
openDevTools: "ブラウザ開発ツールを開く",
|
openDevTools: "ブラウザ開発ツールを開く",
|
||||||
cancelSelector: "要素セレクターをキャンセル",
|
cancelSelector: "要素セレクターをキャンセル",
|
||||||
selectElement: "要素を選択",
|
selectElement: "要素を選択",
|
||||||
grabElement: "要素をクリップボードにコピー",
|
|
||||||
grabElementLabel: "要素",
|
|
||||||
grabFailed: "要素をコピーできませんでした",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "エージェントにフィードバックを送信",
|
|
||||||
placeholder: "変更すべき内容を記述…",
|
|
||||||
submit: "添付",
|
|
||||||
cancel: "キャンセル",
|
|
||||||
intents: {
|
|
||||||
fix: "修正",
|
|
||||||
change: "変更",
|
|
||||||
question: "質問",
|
|
||||||
approve: "承認",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "デバイスサイズ",
|
|
||||||
responsive: "レスポンシブ",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "ページの読み込みに失敗しました",
|
failedToLoad: "ページの読み込みに失敗しました",
|
||||||
@@ -801,7 +782,6 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "履歴",
|
sessions: "履歴",
|
||||||
schedules: "スケジュール",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "ワークツリースクリプトを設定",
|
title: "ワークツリースクリプトを設定",
|
||||||
|
|||||||
@@ -427,25 +427,6 @@ export const ptBR: TranslationResources = {
|
|||||||
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
|
openDevTools: "Abrir ferramentas de desenvolvedor do navegador",
|
||||||
cancelSelector: "Cancelar seletor de elemento",
|
cancelSelector: "Cancelar seletor de elemento",
|
||||||
selectElement: "Selecionar elemento",
|
selectElement: "Selecionar elemento",
|
||||||
grabElement: "Copiar elemento para a área de transferência",
|
|
||||||
grabElementLabel: "elemento",
|
|
||||||
grabFailed: "Não foi possível copiar o elemento",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "Enviar feedback ao agente",
|
|
||||||
placeholder: "Descreva o que deve mudar…",
|
|
||||||
submit: "Anexar",
|
|
||||||
cancel: "Cancelar",
|
|
||||||
intents: {
|
|
||||||
fix: "Corrigir",
|
|
||||||
change: "Alterar",
|
|
||||||
question: "Pergunta",
|
|
||||||
approve: "Aprovar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "Tamanho do dispositivo",
|
|
||||||
responsive: "Responsivo",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "Falha ao carregar página",
|
failedToLoad: "Falha ao carregar página",
|
||||||
@@ -807,7 +788,6 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "Histórico",
|
sessions: "Histórico",
|
||||||
schedules: "Agendamentos",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Configurar scripts de worktree",
|
title: "Configurar scripts de worktree",
|
||||||
|
|||||||
@@ -427,25 +427,6 @@ export const ru: TranslationResources = {
|
|||||||
openDevTools: "Открыть инструменты разработки браузера",
|
openDevTools: "Открыть инструменты разработки браузера",
|
||||||
cancelSelector: "Отменить выбор элемента",
|
cancelSelector: "Отменить выбор элемента",
|
||||||
selectElement: "Выберите элемент",
|
selectElement: "Выберите элемент",
|
||||||
grabElement: "Скопировать элемент в буфер обмена",
|
|
||||||
grabElementLabel: "элемент",
|
|
||||||
grabFailed: "Не удалось скопировать элемент",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "Отправить отзыв агенту",
|
|
||||||
placeholder: "Опишите, что нужно изменить…",
|
|
||||||
submit: "Прикрепить",
|
|
||||||
cancel: "Отмена",
|
|
||||||
intents: {
|
|
||||||
fix: "Исправить",
|
|
||||||
change: "Изменить",
|
|
||||||
question: "Вопрос",
|
|
||||||
approve: "Одобрить",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "Размер устройства",
|
|
||||||
responsive: "Адаптивный",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "Не удалось загрузить страницу",
|
failedToLoad: "Не удалось загрузить страницу",
|
||||||
@@ -808,7 +789,6 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "История",
|
sessions: "История",
|
||||||
schedules: "Расписания",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "Настройка сценариев рабочего дерева",
|
title: "Настройка сценариев рабочего дерева",
|
||||||
|
|||||||
@@ -423,25 +423,6 @@ export const zhCN: TranslationResources = {
|
|||||||
openDevTools: "打开浏览器开发者工具",
|
openDevTools: "打开浏览器开发者工具",
|
||||||
cancelSelector: "取消元素选择器",
|
cancelSelector: "取消元素选择器",
|
||||||
selectElement: "选择元素",
|
selectElement: "选择元素",
|
||||||
grabElement: "复制元素到剪贴板",
|
|
||||||
grabElementLabel: "元素",
|
|
||||||
grabFailed: "复制元素失败",
|
|
||||||
},
|
|
||||||
annotate: {
|
|
||||||
title: "发送反馈给智能体",
|
|
||||||
placeholder: "描述需要修改的内容…",
|
|
||||||
submit: "附加",
|
|
||||||
cancel: "取消",
|
|
||||||
intents: {
|
|
||||||
fix: "修复",
|
|
||||||
change: "修改",
|
|
||||||
question: "提问",
|
|
||||||
approve: "认可",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
label: "设备尺寸",
|
|
||||||
responsive: "自适应",
|
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToLoad: "页面加载失败",
|
failedToLoad: "页面加载失败",
|
||||||
@@ -783,7 +764,6 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
sessions: "历史",
|
sessions: "历史",
|
||||||
schedules: "计划",
|
|
||||||
},
|
},
|
||||||
worktreeSetup: {
|
worktreeSetup: {
|
||||||
title: "设置 worktree scripts",
|
title: "设置 worktree scripts",
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ interface HelpSectionCase {
|
|||||||
describe("keyboard-shortcuts", () => {
|
describe("keyboard-shortcuts", () => {
|
||||||
const matchingCases: MatchingShortcutCase[] = [
|
const matchingCases: MatchingShortcutCase[] = [
|
||||||
{
|
{
|
||||||
name: "matches Cmd+O to open project",
|
name: "matches Mod+Shift+O to create new agent",
|
||||||
event: { key: "o", code: "KeyO", metaKey: true },
|
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
|
||||||
context: { isMac: true },
|
context: { isMac: true },
|
||||||
action: "agent.new",
|
action: "agent.new",
|
||||||
},
|
},
|
||||||
@@ -227,8 +227,8 @@ describe("keyboard-shortcuts", () => {
|
|||||||
action: "workspace.tab.close.current",
|
action: "workspace.tab.close.current",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches Ctrl+O to open project on non-mac",
|
name: "matches Ctrl+Shift+O to create new agent on non-mac",
|
||||||
event: { key: "o", code: "KeyO", ctrlKey: true },
|
event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true },
|
||||||
context: { isMac: false },
|
context: { isMac: false },
|
||||||
action: "agent.new",
|
action: "agent.new",
|
||||||
},
|
},
|
||||||
@@ -394,16 +394,6 @@ describe("keyboard-shortcuts", () => {
|
|||||||
name: "does not keep old Alt+Shift+T binding",
|
name: "does not keep old Alt+Shift+T binding",
|
||||||
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
|
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",
|
name: "does not match question-mark shortcut inside editable scopes",
|
||||||
event: { key: "?", code: "Slash", shiftKey: true },
|
event: { key: "?", code: "Slash", shiftKey: true },
|
||||||
@@ -590,7 +580,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
name: "uses web defaults for workspace and tab jump",
|
name: "uses web defaults for workspace and tab jump",
|
||||||
context: { isMac: true, isDesktop: false },
|
context: { isMac: true, isDesktop: false },
|
||||||
expectedKeys: {
|
expectedKeys: {
|
||||||
"new-agent": ["mod", "O"],
|
"new-agent": ["mod", "shift", "O"],
|
||||||
"workspace-tab-new": ["mod", "T"],
|
"workspace-tab-new": ["mod", "T"],
|
||||||
"workspace-jump-index": ["alt", "1-9"],
|
"workspace-jump-index": ["alt", "1-9"],
|
||||||
"workspace-tab-jump-index": ["alt", "shift", "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",
|
name: "uses desktop defaults for workspace and tab jump",
|
||||||
context: { isMac: true, isDesktop: true },
|
context: { isMac: true, isDesktop: true },
|
||||||
expectedKeys: {
|
expectedKeys: {
|
||||||
"new-agent": ["mod", "O"],
|
"new-agent": ["mod", "shift", "O"],
|
||||||
"new-workspace": ["mod", "N"],
|
"new-workspace": ["mod", "N"],
|
||||||
"workspace-tab-new": ["mod", "T"],
|
"workspace-tab-new": ["mod", "T"],
|
||||||
"workspace-jump-index": ["mod", "1-9"],
|
"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> = {
|
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||||
"new-agent": "settings.shortcuts.help.openProject",
|
"new-agent": "settings.shortcuts.help.openProject",
|
||||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||||
|
"new-worktree": "settings.shortcuts.help.newWorktree",
|
||||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
||||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||||
@@ -165,33 +166,29 @@ const SHORTCUT_HELP_NOTE_KEYS: Record<string, string> = {
|
|||||||
// --- Binding definitions ---
|
// --- Binding definitions ---
|
||||||
|
|
||||||
const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||||
// --- Open project ---
|
// --- New agent ---
|
||||||
// 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.
|
|
||||||
{
|
{
|
||||||
id: "agent-new-cmd-shift-o-mac",
|
id: "agent-new-cmd-shift-o-mac",
|
||||||
action: "agent.new",
|
action: "agent.new",
|
||||||
combo: "Cmd+O",
|
combo: "Cmd+Shift+O",
|
||||||
when: { mac: true },
|
when: { mac: true },
|
||||||
help: {
|
help: {
|
||||||
id: "new-agent",
|
id: "new-agent",
|
||||||
section: "projects",
|
section: "projects",
|
||||||
label: "Open project",
|
label: "Open project",
|
||||||
keys: ["mod", "O"],
|
keys: ["mod", "shift", "O"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent-new-ctrl-shift-o-non-mac",
|
id: "agent-new-ctrl-shift-o-non-mac",
|
||||||
action: "agent.new",
|
action: "agent.new",
|
||||||
combo: "Ctrl+O",
|
combo: "Ctrl+Shift+O",
|
||||||
when: { mac: false, terminal: false },
|
when: { mac: false, terminal: false },
|
||||||
help: {
|
help: {
|
||||||
id: "new-agent",
|
id: "new-agent",
|
||||||
section: "projects",
|
section: "projects",
|
||||||
label: "Open project",
|
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 ---
|
// --- Archive worktree ---
|
||||||
{
|
{
|
||||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
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 ROOT_HOST_ROUTE_NAME = "h/[serverId]";
|
||||||
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
|
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 =
|
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
interface NavigateToHostWorkspaceRouteOptions {
|
||||||
|
popToExistingHostRoute?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerWorkspaceRouteNavigationRef(
|
export function registerWorkspaceRouteNavigationRef(
|
||||||
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
||||||
): () => void {
|
): () => 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") {
|
if (!state || typeof state !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = state as {
|
const candidate = state as {
|
||||||
key?: unknown;
|
key?: unknown;
|
||||||
|
routeNames?: unknown;
|
||||||
routes?: unknown;
|
routes?: unknown;
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
typeof candidate.key === "string" &&
|
||||||
|
Array.isArray(candidate.routeNames) &&
|
||||||
|
candidate.routeNames.includes(routeName)
|
||||||
|
) {
|
||||||
|
return candidate.key;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(candidate.routes)) {
|
if (!Array.isArray(candidate.routes)) {
|
||||||
return null;
|
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) {
|
for (const route of candidate.routes) {
|
||||||
if (!route || typeof route !== "object") {
|
if (!route || typeof route !== "object") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const childKey = findStackKeyWithMountedRouteName(
|
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName);
|
||||||
(route as { state?: unknown }).state,
|
|
||||||
routeName,
|
|
||||||
);
|
|
||||||
if (childKey) {
|
if (childKey) {
|
||||||
return childKey;
|
return childKey;
|
||||||
}
|
}
|
||||||
@@ -78,7 +69,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootState = navigation.getRootState();
|
const rootState = navigation.getRootState();
|
||||||
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -108,11 +99,11 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
|
|||||||
|
|
||||||
export function navigateToHostWorkspaceRoute(
|
export function navigateToHostWorkspaceRoute(
|
||||||
route: string,
|
route: string,
|
||||||
deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps,
|
options: NavigateToHostWorkspaceRouteOptions = {},
|
||||||
): void {
|
): void {
|
||||||
if (dispatchHostWorkspacePopTo(route)) {
|
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.dismissTo(route);
|
router.dismissTo(route as Href);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export type AgentFormAction =
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
availableModels: AgentModelDefinition[] | null;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
}
|
}
|
||||||
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
|
||||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
| { type: "SET_WORKING_DIR"; value: string }
|
| { type: "SET_WORKING_DIR"; value: string }
|
||||||
| { type: "SET_WORKING_DIR_FROM_USER"; 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":
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
return {
|
return {
|
||||||
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true });
|
|
||||||
});
|
|
||||||
import type {
|
import type {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
@@ -20,10 +16,6 @@ import {
|
|||||||
type HostRuntimeStorage,
|
type HostRuntimeStorage,
|
||||||
} from "./host-runtime";
|
} from "./host-runtime";
|
||||||
|
|
||||||
vi.mock("@/browser-automation/handler", () => ({
|
|
||||||
mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
private state: ConnectionState = { status: "idle" };
|
private state: ConnectionState = { status: "idle" };
|
||||||
private listeners = new Set<(status: ConnectionState) => void>();
|
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", () => {
|
describe("HostRuntimeController", () => {
|
||||||
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
||||||
const oldRelay: HostConnection = {
|
const oldRelay: HostConnection = {
|
||||||
@@ -478,39 +458,6 @@ describe("HostRuntimeController", () => {
|
|||||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
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 () => {
|
it("adopts the first successful probe on startup", async () => {
|
||||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||||
const clients: FakeDaemonClient[] = [];
|
const clients: FakeDaemonClient[] = [];
|
||||||
@@ -1484,7 +1431,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
entries: [
|
entries: [
|
||||||
makeFetchAgentsEntry({
|
makeFetchAgentsEntry({
|
||||||
id: "agent-recent",
|
id: "agent-recent",
|
||||||
cwd: "/workspaces/paseo",
|
cwd: "/Users/moboudra/dev/paseo",
|
||||||
updatedAt: "2026-03-04T12:00:00.000Z",
|
updatedAt: "2026-03-04T12:00:00.000Z",
|
||||||
title: "Recent agent",
|
title: "Recent agent",
|
||||||
}),
|
}),
|
||||||
@@ -1497,7 +1444,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
entries: [
|
entries: [
|
||||||
makeFetchAgentsEntry({
|
makeFetchAgentsEntry({
|
||||||
id: "agent-stale-attention",
|
id: "agent-stale-attention",
|
||||||
cwd: "/workspaces/paseo-pr67-review",
|
cwd: "/Users/moboudra/dev/paseo-pr67-review",
|
||||||
updatedAt: "2026-02-20T08:00:00.000Z",
|
updatedAt: "2026-02-20T08:00:00.000Z",
|
||||||
title: "Needs triage",
|
title: "Needs triage",
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
@@ -1665,7 +1612,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
useSessionStore.getState().setAgents(host.serverId, () => {
|
useSessionStore.getState().setAgents(host.serverId, () => {
|
||||||
const stale = makeFetchAgentsEntry({
|
const stale = makeFetchAgentsEntry({
|
||||||
id: "agent-archived",
|
id: "agent-archived",
|
||||||
cwd: "/workspaces/paseo",
|
cwd: "/Users/moboudra/dev/paseo",
|
||||||
updatedAt: "2026-03-30T15:29:00.000Z",
|
updatedAt: "2026-03-30T15:29:00.000Z",
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
title: "Stale active copy",
|
title: "Stale active copy",
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ import {
|
|||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createDesktopLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
} 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 { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +45,6 @@ import {
|
|||||||
} from "@/workspace/legacy-daemon-workspaces";
|
} from "@/workspace/legacy-daemon-workspaces";
|
||||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
export type HostRegistryStatus = "loading" | "ready";
|
export type HostRegistryStatus = "loading" | "ready";
|
||||||
@@ -141,11 +138,6 @@ export interface HostRuntimeControllerDeps {
|
|||||||
}>;
|
}>;
|
||||||
getClientId: () => Promise<string>;
|
getClientId: () => Promise<string>;
|
||||||
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
|
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
|
||||||
mountClientHandlers?: (input: {
|
|
||||||
client: DaemonClient;
|
|
||||||
host: HostProfile;
|
|
||||||
connection: HostConnection;
|
|
||||||
}) => () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostRuntimeStorage {
|
export interface HostRuntimeStorage {
|
||||||
@@ -522,12 +514,6 @@ function probeIntervalForConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||||
const desktopBrowserAutomationAvailable =
|
|
||||||
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
|
||||||
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
|
|
||||||
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||||
@@ -537,7 +523,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
appVersion: resolveAppVersion() ?? undefined,
|
appVersion: resolveAppVersion() ?? undefined,
|
||||||
runtimeGeneration,
|
runtimeGeneration,
|
||||||
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
|
|
||||||
};
|
};
|
||||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
@@ -575,15 +560,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
connectToDaemon(connection, {
|
connectToDaemon(connection, {
|
||||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||||
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
|
|
||||||
}),
|
}),
|
||||||
getClientId: () => getOrCreateClientId(),
|
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 listeners = new Set<() => void>();
|
||||||
private activeClient: DaemonClient | null = null;
|
private activeClient: DaemonClient | null = null;
|
||||||
private unsubscribeClientStatus: (() => void) | null = null;
|
private unsubscribeClientStatus: (() => void) | null = null;
|
||||||
private unsubscribeClientHandlers: (() => void) | null = null;
|
|
||||||
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
private started = false;
|
private started = false;
|
||||||
private connectionFirstSeenAt = new Map<string, number>();
|
private connectionFirstSeenAt = new Map<string, number>();
|
||||||
@@ -679,10 +656,6 @@ export class HostRuntimeController {
|
|||||||
this.unsubscribeClientStatus();
|
this.unsubscribeClientStatus();
|
||||||
this.unsubscribeClientStatus = null;
|
this.unsubscribeClientStatus = null;
|
||||||
}
|
}
|
||||||
if (this.unsubscribeClientHandlers) {
|
|
||||||
this.unsubscribeClientHandlers();
|
|
||||||
this.unsubscribeClientHandlers = null;
|
|
||||||
}
|
|
||||||
if (this.activeClient) {
|
if (this.activeClient) {
|
||||||
const prev = this.activeClient;
|
const prev = this.activeClient;
|
||||||
this.activeClient = null;
|
this.activeClient = null;
|
||||||
@@ -1160,10 +1133,6 @@ export class HostRuntimeController {
|
|||||||
this.unsubscribeClientStatus();
|
this.unsubscribeClientStatus();
|
||||||
this.unsubscribeClientStatus = null;
|
this.unsubscribeClientStatus = null;
|
||||||
}
|
}
|
||||||
if (this.unsubscribeClientHandlers) {
|
|
||||||
this.unsubscribeClientHandlers();
|
|
||||||
this.unsubscribeClientHandlers = null;
|
|
||||||
}
|
|
||||||
if (this.activeClient) {
|
if (this.activeClient) {
|
||||||
const previousClient = this.activeClient;
|
const previousClient = this.activeClient;
|
||||||
this.activeClient = null;
|
this.activeClient = null;
|
||||||
@@ -1237,8 +1206,6 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeClient = client;
|
this.activeClient = client;
|
||||||
this.unsubscribeClientHandlers =
|
|
||||||
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
|
||||||
this.applyConnectionEvent({
|
this.applyConnectionEvent({
|
||||||
type: "select_connection",
|
type: "select_connection",
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleHostSection {
|
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
isOnline: boolean;
|
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesInput {
|
|
||||||
hosts: ScheduleHostInput[];
|
|
||||||
runtime: ScheduleRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesResult {
|
|
||||||
sections: ScheduleHostSection[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAggregatedSchedules(
|
|
||||||
input: FetchAggregatedSchedulesInput,
|
|
||||||
): Promise<FetchAggregatedSchedulesResult> {
|
|
||||||
const sections = await Promise.all(
|
|
||||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
|
||||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
|
||||||
const isOnline = snapshot?.connectionStatus === "online";
|
|
||||||
const client = input.runtime.getClient(host.serverId);
|
|
||||||
|
|
||||||
if (!client || !isOnline) {
|
|
||||||
return {
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline,
|
|
||||||
schedules: [],
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await client.scheduleList();
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline,
|
|
||||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline,
|
|
||||||
schedules: [],
|
|
||||||
error: toErrorMessage(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { sections };
|
|
||||||
}
|
|
||||||
@@ -56,9 +56,12 @@ import { toErrorMessage } from "@/utils/error-messages";
|
|||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import {
|
import {
|
||||||
|
filterWorkspaceProjectsForHost,
|
||||||
getHostProjectSourceDirectory,
|
getHostProjectSourceDirectory,
|
||||||
hostProjectFromRoute,
|
hostProjectFromRoute,
|
||||||
hostProjectFromWorkspace,
|
hostProjectFromWorkspace,
|
||||||
|
resolveInitialWorkspaceProject,
|
||||||
|
resolveSelectedHostProject,
|
||||||
useHostProjects,
|
useHostProjects,
|
||||||
type HostProjectListItem,
|
type HostProjectListItem,
|
||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
@@ -85,7 +88,6 @@ import {
|
|||||||
resolveNewWorkspaceAutomaticServerId,
|
resolveNewWorkspaceAutomaticServerId,
|
||||||
resolveNewWorkspaceInitialServerId,
|
resolveNewWorkspaceInitialServerId,
|
||||||
} from "./new-workspace-initial-context";
|
} from "./new-workspace-initial-context";
|
||||||
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
|
|
||||||
|
|
||||||
function resolveCheckoutRequest(
|
function resolveCheckoutRequest(
|
||||||
selectedItem: PickerItem | null,
|
selectedItem: PickerItem | null,
|
||||||
@@ -166,6 +168,7 @@ interface PickerSelection {
|
|||||||
|
|
||||||
const BRANCH_OPTION_PREFIX = "branch:";
|
const BRANCH_OPTION_PREFIX = "branch:";
|
||||||
const PR_OPTION_PREFIX = "github-pr:";
|
const PR_OPTION_PREFIX = "github-pr:";
|
||||||
|
const PROJECT_OPTION_PREFIX = "project:";
|
||||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||||
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
|
// 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.
|
// 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}`;
|
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({
|
function NewWorkspacePickerOption({
|
||||||
option,
|
option,
|
||||||
selected,
|
selected,
|
||||||
@@ -1147,6 +1164,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
currentPathname: "/new",
|
||||||
target: submission.target,
|
target: submission.target,
|
||||||
});
|
});
|
||||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||||
@@ -1269,6 +1287,7 @@ interface NewWorkspaceInitialContextState {
|
|||||||
projects: HostProjectListItem[];
|
projects: HostProjectListItem[];
|
||||||
routeProject: HostProjectListItem | null;
|
routeProject: HostProjectListItem | null;
|
||||||
lastActiveProject: HostProjectListItem | null;
|
lastActiveProject: HostProjectListItem | null;
|
||||||
|
routeDisplayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNewWorkspaceInitialContext({
|
function useNewWorkspaceInitialContext({
|
||||||
@@ -1335,6 +1354,112 @@ function useNewWorkspaceInitialContext({
|
|||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
lastActiveProject,
|
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,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
|
routeDisplayName,
|
||||||
} = useNewWorkspaceInitialContext({
|
} = useNewWorkspaceInitialContext({
|
||||||
serverId,
|
serverId,
|
||||||
sourceDirectory: sourceDirectoryProp,
|
sourceDirectory: sourceDirectoryProp,
|
||||||
@@ -1621,6 +1747,7 @@ export function NewWorkspaceScreen({
|
|||||||
projects,
|
projects,
|
||||||
routeProject,
|
routeProject,
|
||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
|
displayName: routeDisplayName,
|
||||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1993,7 +2120,7 @@ export function NewWorkspaceScreen({
|
|||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
navigate: (targetServerId, workspaceId) =>
|
navigate: (targetServerId, workspaceId) =>
|
||||||
navigateToWorkspace(targetServerId, workspaceId),
|
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }),
|
||||||
});
|
});
|
||||||
return;
|
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,238 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState, 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 { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
|
||||||
import { SchedulesTable } from "@/components/schedules/schedules-table";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
||||||
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
|
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
||||||
|
|
||||||
type FormState =
|
|
||||||
| { mode: "closed" }
|
|
||||||
| { mode: "create" }
|
|
||||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
|
||||||
|
|
||||||
export function SchedulesScreen(): ReactElement {
|
|
||||||
const isFocused = useIsFocused();
|
|
||||||
|
|
||||||
if (!isFocused) {
|
|
||||||
return <View style={styles.container} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SchedulesScreenContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SchedulesScreenContent(): ReactElement {
|
|
||||||
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
|
||||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
|
||||||
setForm({ mode: "create" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
|
|
||||||
setForm({ mode: "edit", serverId, schedule });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeForm = useCallback(() => {
|
|
||||||
setForm({ mode: "closed" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const headerAction = useMemo(
|
|
||||||
() => (
|
|
||||||
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
|
|
||||||
New schedule
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
[openCreate],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
|
||||||
<SchedulesBody
|
|
||||||
sections={sections}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
error={error}
|
|
||||||
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 SchedulesBody({
|
|
||||||
sections,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
onRetry,
|
|
||||||
onCreate,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
sections: ScheduleHostSection[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
onRetry: () => void;
|
|
||||||
onCreate: () => void;
|
|
||||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centered}>
|
|
||||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centered}>
|
|
||||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
|
||||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sections.length === 0) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centered}>
|
|
||||||
<Text style={styles.message}>No schedules yet</Text>
|
|
||||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
|
||||||
New schedule
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scroll}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
testID="schedules-sections"
|
|
||||||
>
|
|
||||||
{sections.map((section) => (
|
|
||||||
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScheduleHostSectionView({
|
|
||||||
section,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
section: ScheduleHostSection;
|
|
||||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const handleEdit = useCallback(
|
|
||||||
(schedule: ScheduleSummary) => {
|
|
||||||
onEdit(section.serverId, schedule);
|
|
||||||
},
|
|
||||||
[onEdit, section.serverId],
|
|
||||||
);
|
|
||||||
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
|
||||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
|
||||||
{section.serverName}
|
|
||||||
</Text>
|
|
||||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
|
||||||
{section.schedules.length > 0 ? (
|
|
||||||
<SchedulesTable
|
|
||||||
serverId={section.serverId}
|
|
||||||
schedules={section.schedules}
|
|
||||||
onEditSchedule={handleEdit}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{section.schedules.length === 0 && !section.error ? (
|
|
||||||
<View style={styles.sectionEmpty}>
|
|
||||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
centered: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[6],
|
|
||||||
padding: theme.spacing[6],
|
|
||||||
},
|
|
||||||
scroll: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
paddingTop: theme.spacing[4],
|
|
||||||
paddingBottom: theme.spacing[6],
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 720,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
sectionError: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 720,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
sectionEmpty: {
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 720,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
},
|
|
||||||
sectionEmptyText: {
|
|
||||||
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,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 { ICON_SIZE } from "@/styles/theme";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
|
||||||
|
|
||||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||||
const ThemedArrowDown = withUnistyles(ArrowDown);
|
const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||||
@@ -263,7 +262,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
|||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<SettingsSection title={t("settings.hostSections.agents")}>
|
<SettingsSection title={t("settings.hostSections.agents")}>
|
||||||
<InjectPaseoToolsCard serverId={serverId} />
|
<InjectPaseoToolsCard serverId={serverId} />
|
||||||
<BrowserToolsOptInCard serverId={serverId} />
|
|
||||||
<AppendSystemPromptCard serverId={serverId} />
|
<AppendSystemPromptCard serverId={serverId} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
|||||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||||
return {
|
return {
|
||||||
mcp: { injectIntoAgents: false },
|
mcp: { injectIntoAgents: false },
|
||||||
browserTools: { enabled: false },
|
|
||||||
providers,
|
providers,
|
||||||
metadataGeneration: { providers: [] },
|
metadataGeneration: { providers: [] },
|
||||||
autoArchiveAfterMerge: false,
|
autoArchiveAfterMerge: false,
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
|||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { removeResidentBrowserWebview } from "@/components/browser-webview-resident";
|
|
||||||
import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
|
import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
@@ -305,22 +304,19 @@ function decodeSegment(value: string): string {
|
|||||||
function useSyncWorkspaceActiveBrowser(input: {
|
function useSyncWorkspaceActiveBrowser(input: {
|
||||||
workspaceLayout: WorkspaceLayout | null;
|
workspaceLayout: WorkspaceLayout | null;
|
||||||
isRouteFocused: boolean;
|
isRouteFocused: boolean;
|
||||||
workspaceId: string;
|
|
||||||
}) {
|
}) {
|
||||||
const focusedBrowserId = useMemo(
|
const focusedBrowserId = useMemo(
|
||||||
() => getFocusedBrowserId(input.workspaceLayout),
|
() => getFocusedBrowserId(input.workspaceLayout),
|
||||||
[input.workspaceLayout],
|
[input.workspaceLayout],
|
||||||
);
|
);
|
||||||
|
const desktopActiveBrowserId = input.isRouteFocused ? focusedBrowserId : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getIsElectron()) {
|
if (!getIsElectron()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.({
|
void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.(desktopActiveBrowserId);
|
||||||
workspaceId: input.workspaceId,
|
}, [desktopActiveBrowserId]);
|
||||||
browserId: focusedBrowserId,
|
|
||||||
});
|
|
||||||
}, [focusedBrowserId, input.workspaceId]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackTabOptionLabel(
|
function getFallbackTabOptionLabel(
|
||||||
@@ -1880,11 +1876,7 @@ function WorkspaceScreenContent({
|
|||||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||||
[workspaceLayout],
|
[workspaceLayout],
|
||||||
);
|
);
|
||||||
useSyncWorkspaceActiveBrowser({
|
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
||||||
workspaceLayout,
|
|
||||||
isRouteFocused,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
||||||
(state) => state.openTabInBackground,
|
(state) => state.openTabInBackground,
|
||||||
);
|
);
|
||||||
@@ -1929,7 +1921,6 @@ function WorkspaceScreenContent({
|
|||||||
if (input.target?.kind === "browser") {
|
if (input.target?.kind === "browser") {
|
||||||
const { browserId } = input.target;
|
const { browserId } = input.target;
|
||||||
useBrowserStore.getState().removeBrowser(browserId);
|
useBrowserStore.getState().removeBrowser(browserId);
|
||||||
removeResidentBrowserWebview(browserId);
|
|
||||||
void getDesktopHost()?.browser?.clearPartition?.(browserId);
|
void getDesktopHost()?.browser?.clearPartition?.(browserId);
|
||||||
}
|
}
|
||||||
closeWorkspaceTab(persistenceKey, normalizedTabId);
|
closeWorkspaceTab(persistenceKey, normalizedTabId);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
|
||||||
import {
|
import {
|
||||||
applyClearDraftRecord,
|
applyClearDraftRecord,
|
||||||
collectReferencedAttachmentIdsFromState,
|
collectReferencedAttachmentIdsFromState,
|
||||||
@@ -142,18 +141,6 @@ async function runAttachmentGc(): Promise<void> {
|
|||||||
collectStreamUserImageIds(session.agentStreamHead, referencedIds);
|
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 {
|
try {
|
||||||
await garbageCollectAttachments({ referencedIds });
|
await garbageCollectAttachments({ referencedIds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navig
|
|||||||
|
|
||||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||||
|
|
||||||
|
interface NavigateToWorkspaceOptions {
|
||||||
|
currentPathname?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||||
@@ -29,7 +33,13 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
|||||||
lastWorkspaceSelectionStorage,
|
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 {
|
return {
|
||||||
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
||||||
getSessionAgents: (serverId) =>
|
getSessionAgents: (serverId) =>
|
||||||
@@ -39,7 +49,9 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
|||||||
},
|
},
|
||||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||||
navigateToRoute: (route) => {
|
navigateToRoute: (route) => {
|
||||||
navigateToHostWorkspaceRoute(route);
|
navigateToHostWorkspaceRoute(route, {
|
||||||
|
popToExistingHostRoute: shouldPopToExistingHostRoute(options),
|
||||||
|
});
|
||||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -57,13 +69,17 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
|||||||
return lastWorkspaceSelectionStore.isHydrated();
|
return lastWorkspaceSelectionStore.isHydrated();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
export function navigateToWorkspace(
|
||||||
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
options: NavigateToWorkspaceOptions = {},
|
||||||
|
) {
|
||||||
|
navigateToWorkspacePure(serverId, workspaceId, navigateDeps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToLastWorkspace(): boolean {
|
export function navigateToLastWorkspace(): boolean {
|
||||||
return navigateToLastWorkspacePure({
|
return navigateToLastWorkspacePure({
|
||||||
...navigateDeps(),
|
...navigateDeps({}),
|
||||||
getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(),
|
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", () => {
|
it("navigates to the last workspace once a route observation has been remembered", () => {
|
||||||
const { deps, navigations } = createLastSelectionDeps(null);
|
const { deps, navigations } = createLastSelectionDeps(null);
|
||||||
|
|
||||||
|
|||||||
@@ -58,16 +58,10 @@ function parseWorkspaceSelectionFromRouteParams(params: {
|
|||||||
export function parseActiveWorkspaceSelection(
|
export function parseActiveWorkspaceSelection(
|
||||||
input: RouteSelectionInput,
|
input: RouteSelectionInput,
|
||||||
): ActiveWorkspaceSelection | null {
|
): ActiveWorkspaceSelection | null {
|
||||||
const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
|
return (
|
||||||
if (routeSelection) {
|
parseHostWorkspaceRouteFromPathname(input.pathname) ??
|
||||||
return routeSelection;
|
parseWorkspaceSelectionFromRouteParams(input.params)
|
||||||
}
|
);
|
||||||
|
|
||||||
if (input.pathname !== "/" && input.pathname !== "") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseWorkspaceSelectionFromRouteParams(input.params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToWorkspace(
|
export function navigateToWorkspace(
|
||||||
|
|||||||
@@ -63,9 +63,4 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
marginTop: theme.spacing[1],
|
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(
|
setAssistantImageMetadata(
|
||||||
{
|
{
|
||||||
source: "/tmp/paseo-codex-screenshot.png",
|
source: "/tmp/paseo-codex-screenshot.png",
|
||||||
workspaceRoot: "/workspaces/paseo",
|
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
},
|
},
|
||||||
{ width: 1200, height: 800 },
|
{ width: 1200, height: 800 },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user