Compare commits

..

16 Commits

Author SHA1 Message Date
Mohamed Boudra
f3fdeab131 chore(release): cut 0.1.104 2026-07-08 12:47:07 +02:00
Mohamed Boudra
fdc6a4a57d docs: changelog for v0.1.104 2026-07-08 12:44:08 +02:00
Mohamed Boudra
ed67e6e0f1 fix(app): preserve renamed host labels (#1940)
Daemon-advertised hostnames still name new/default hosts, but connection refreshes and re-pairing no longer overwrite labels that users already changed.
2026-07-08 10:20:06 +00:00
Mohamed Boudra
5ec7ef9771 perf(agent-tools): shrink MCP tool catalog context cost (#1939)
Drop advertised MCP output schemas (no supported harness forwards them to the model) and remove the redundant wait_for_agent tool. Core agent tool catalog drops ~20.5k -> ~5.6k estimated tokens; ~24.9k -> ~9k with browser tools. Runtime structuredContent is preserved via server-side text serialization. Also corrects the agent-wait timeout guidance for blocking callers.
2026-07-08 11:39:39 +02:00
Mohamed Boudra
4fb70668f2 Manage the built-in daemon from one place in settings (#1938)
* feat(settings): consolidate daemon management into the host page

The app-level Daemon section and the localhost host page rendered the
same daemon card in two places. The host page is now the single home:
the Host sidebar group leads with Overview, and a desktop-only "Enable
built-in daemon" option in the host switcher recovers a removed
localhost connection. Old settings/daemon deep links redirect
(COMPAT-tagged).

* fix(settings): hide daemon recovery option when the server id probe fails

Review feedback: !query.isLoading treated an errored IPC probe as
resolved, so the recovery affordance could show without knowing the
local server id. Replace the resolved-boolean hook with a single
discriminated state hook (loading | error | resolved) — errors hide the
affordance and fall back to the settings root in the redirect. Also
collapses the two query subscriptions per caller into one.

* fix(settings): keep daemon recovery reachable when no hosts remain

Review feedback: removing localhost as the only host takes the settings
sidebar down its no-host branch, which never mounts the host picker —
so the "Enable built-in daemon" option was unreachable in the most
common recovery case. The sidebar now owns the single hook call, passes
it to the picker, and renders the same action as a sidebar row next to
"Add host" when no hosts exist.

* test(e2e): update settings host header to Overview

Test drift: the host section's sidebar/header label was intentionally
renamed to "Overview"; the spec still asserted the old "Host" title.
Also assert the retired app-level Daemon row stays absent.
2026-07-08 10:20:30 +02:00
paseo-ai[bot]
06c3161008 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-07 21:06:43 +00:00
Mohamed Boudra
a85139c3f3 chore(release): cut 0.1.104-beta.5 2026-07-07 23:01:34 +02:00
Mohamed Boudra
be756c9004 docs: changelog for v0.1.104-beta.5 2026-07-07 22:58:17 +02:00
Mohamed Boudra
8d0b3e69e8 chore(providers): update ACP catalog pins 2026-07-07 22:57:49 +02:00
Mohamed Boudra
d9b65e269f Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
2026-07-07 22:13:42 +02:00
Mohamed Boudra
d3e1fe48f6 Scheduled and loop agents get workspaces; schedules UI speaks the user's timezone (#1909)
* fix(app): schedules UI speaks the user's timezone

* refactor(server): require explicit agent placement and bind create-agent command

* fix(server): scheduled agents get a workspace and show in the sidebar

* fix(server): loop agents get a workspace and show in the sidebar

* fix(server): preserve scheduled and loop run semantics through the create command

* fix(server): serialize all schedule record writes through the per-schedule queue

* fix(server): tighten create-agent dependency contracts

* fix(server): handle schedule restamps and loop cancellations

* fix(server): preserve scheduled and loop create semantics

* refactor(server): schedule store owns atomic mutations

* fix(server): keep schedule stamp reuse deterministic

* fix(server): handle loop workspace stamp rejection

* refactor(server): schedule store owns typed identity upsert

* fix(server): keep schedule workspace stamps server-owned

* fix(server): preserve scheduled run semantics

* refactor: required nullable workspaceId replaces placement union; pure cadence policy

* fix(server): tighten scheduled and loop agent edges

* fix(server): fail canceled scheduled runs

* fix(server): close schedule and loop cwd edges

* fix(server): refresh schedule workspace stamps

* fix(server): preserve schedule timezone on replace

* fix(server): close loop stop and cadence reset edges
2026-07-07 17:13:28 +08:00
Mohamed Boudra
783f43c580 Mark bundled-CLI daemons as desktop managed (#1919) 2026-07-06 18:08:18 +02:00
Mohamed Boudra
503a5a9e1e Fix desktop-managed daemon restart from settings (#1915)
* fix(settings): restart managed desktop daemon fully

* fix(settings): respect paused desktop daemon management

* test(settings): cover restart routing through public helper

* fix(settings): wait for daemon restart disconnect

* fix(settings): handle restart review edge cases
2026-07-06 17:50:49 +02:00
Mohamed Boudra
f21c997b17 Include desktop app logs in diagnostics (#1914)
* fix(diagnostics): include desktop app logs

Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports.

* refactor(desktop): share log tail reader

* fix(diagnostics): surface desktop app log read errors

* refactor(diagnostics): tighten desktop report API
2026-07-06 17:50:45 +02:00
Mohamed Boudra
7c1da0077b Fix desktop PATH resolution when shell startup fails (#1916)
* fix(desktop): retry login shell env resolution

Finder-launched desktop sessions can inherit launchd's minimal PATH if the login shell probe times out or never prints its marker. Retry once with a non-interactive login shell so profile PATH still has a chance to apply.

* fix(desktop): cap login shell probe retry budget

* fix(desktop): repair csh login env retry

* test(desktop): pin login shell env retry platform

* refactor(desktop): use object params in shell env helpers
2026-07-06 17:50:42 +02:00
yz
d318629121 Fix default bundled web UI path in packaged desktop CLI (#1899)
* Fix packaged web UI dist dir resolution

* Fix bundled web UI path typecheck

* Fix packaged web UI path matcher

* Remove duplicate packaged web UI clause

* Merge packaged web UI path branch

* Use bundled web UI artifact existence

* Inline bundled web UI existence checks

* Restore original web UI fallback branch

* Reuse app-dist path in web UI resolver

* Format web UI resolver tests and config

* Tidy web UI resolver follow-up

* fix(server): tighten bundled web UI path resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-06 16:24:02 +02:00
181 changed files with 15188 additions and 4431 deletions

View File

@@ -84,6 +84,198 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
},
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
{
"files": [
"packages/app/src/data/**/*.{ts,tsx}",
"packages/app/src/**/*.test.{ts,tsx}",
"packages/app/src/**/*.spec.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
"packages/app/src/components/message.tsx",
"packages/app/src/components/worktree-setup-callout-source.tsx",
"packages/app/src/desktop/hooks/use-daemon-status.ts",
"packages/app/src/desktop/hooks/use-install-status.ts",
"packages/app/src/desktop/settings/desktop-settings.ts",
"packages/app/src/git/pull-request-panel/use-data.ts",
"packages/app/src/git/use-github-search-query.ts",
"packages/app/src/git/use-pr-status-query.ts",
"packages/app/src/git/use-status-query.ts",
"packages/app/src/hooks/use-archive-agent.ts",
"packages/app/src/hooks/use-agent-autocomplete.ts",
"packages/app/src/hooks/use-agent-commands-query.ts",
"packages/app/src/hooks/use-agent-history.ts",
"packages/app/src/hooks/use-branch-switcher.ts",
"packages/app/src/hooks/use-changes-preferences/index.ts",
"packages/app/src/hooks/use-draft-agent-features.ts",
"packages/app/src/hooks/use-form-preferences.ts",
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
"packages/app/src/workspace/desktop-open-targets.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-native-unistyles",
"importNames": ["useUnistyles"],
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/browser-pane.electron.tsx",
"packages/app/src/components/browser-pane.tsx",
"packages/app/src/components/browser-pane.web.tsx",
"packages/app/src/components/command-center.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/explorer-sidebar.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/quitting-overlay.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/resize-handle.tsx",
"packages/app/src/components/rewind/rewind-menu.tsx",
"packages/app/src/components/settings-textarea.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/split-container.tsx",
"packages/app/src/components/split-drop-zone.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/ui/alert.tsx",
"packages/app/src/components/ui/autocomplete.tsx",
"packages/app/src/components/ui/combobox.tsx",
"packages/app/src/components/ui/context-menu.tsx",
"packages/app/src/components/ui/dropdown-menu.tsx",
"packages/app/src/components/ui/external-link.tsx",
"packages/app/src/components/volume-meter.tsx",
"packages/app/src/components/web-desktop-scrollbar.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/agent-controls/mode-control.tsx",
"packages/app/src/constants/layout.ts",
"packages/app/src/desktop/components/desktop-permission-row.tsx",
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
"packages/app/src/desktop/components/desktop-updates-section.tsx",
"packages/app/src/desktop/components/integrations-section.tsx",
"packages/app/src/desktop/updates/update-callout-source.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/sessions-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/settings/settings-group.tsx",
"packages/app/src/screens/startup-splash-screen.tsx",
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
}
]
}
]
}
},
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
{
"files": [
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/file-pane.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/project-picker-modal.tsx",
"packages/app/src/desktop/components/pair-device-section.tsx",
"packages/app/src/screens/new-workspace-screen.tsx"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/e2e/fixtures.ts"],
"rules": {

View File

@@ -1,6 +1,6 @@
# Changelog
## 0.1.104-beta.4 - 2026-07-06
## 0.1.104 - 2026-07-08
### Added
@@ -10,17 +10,27 @@
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831), [#1896](https://github.com/getpaseo/paseo/pull/1896) by [@park0er](https://github.com/park0er))
### Improved
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
### Fixed
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
- Web UI loads when the daemon is started from the bundled CLI ([#1899](https://github.com/getpaseo/paseo/pull/1899) by [@yzim](https://github.com/yzim))
- Worktree setup scripts keep your PATH ([#1908](https://github.com/getpaseo/paseo/pull/1908))
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))

View File

@@ -30,6 +30,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |

View File

@@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
## Store Surface Rules
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
---
## Directory layout
@@ -37,30 +41,6 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
---
## Runtime PID Lock
**Path:** `$PASEO_HOME/paseo.pid`
The daemon writes this file when it starts and removes it when it exits cleanly.
It is a runtime lock, not a durable settings file.
| Field | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `pid` | `number` | Owner process PID for stop/status operations |
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
| `hostname` | `string` | Host that created the lock |
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
Desktop-managed is a read-time classification by the desktop app: the daemon
executable must be the desktop app executable or live inside the same desktop
install. The CLI reports the raw `executablePath` but does not try to classify
it because an npm-installed CLI has no authoritative desktop install root.
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`

View File

@@ -173,6 +173,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
```bash
npm run measure:agent-tools --workspace=@getpaseo/server
```
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array

View File

@@ -20,6 +20,29 @@ input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Popover width contract
Combobox desktop popovers are never narrower than their trigger, and they grow
with content up to a ceiling that is never below the trigger:
```ts
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
```
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
trigger still wins when it is wider. Changing this default requires re-verifying
every consumer listed here.
Consumers: `composer/agent-controls/mode-control.tsx`,
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders

98
docs/forms.md Normal file
View File

@@ -0,0 +1,98 @@
# Forms
The paved road for building forms in the app. The schedule form is the golden
example; when building or fixing any form, copy its shape, not the shape of
whatever screen you happen to be near.
Golden example files:
- `packages/app/src/schedules/schedule-form-model.ts` (+ `.test.ts`) — the model
- `packages/app/src/schedules/use-schedule-form-model.ts` — model lifetime adapter
- `packages/app/src/schedules/use-schedule-form-provider-snapshot.ts` — async input adapter
- `packages/app/src/components/schedules/schedule-form-sheet.tsx` — render + intent dispatch
- `packages/app/src/schedules/aggregated-schedules.ts` / `hooks/use-schedules.ts` — load-state gating
- `packages/app/e2e/schedules-*.spec.ts` — the behavioral contract
## The form model
Every non-trivial form gets a **plain TypeScript model** — zero React imports:
- `openXxxForm(snapshot)` **constructs** a fresh instance from declared inputs
(mode, the record being edited, hosts, defaults). Edit mode seeds every value
AND display from the snapshot — never from a previous instance.
- **Commands** mutate (`setHost`, `setProject(value, display)`, `setModel`, …).
Derived state (disclosure, canSubmit, displays) is recomputed inside the
model on every publish.
- `close()` destroys the instance. `subscribe`/`getState` feed one
`useSyncExternalStore` in the component.
The component renders state and dispatches intent. That is all it does.
### Lifecycle rules (each one killed a real shipped bug)
1. **Fresh mount per open.** The sheet returns `null` when not visible and
mounts the open form with a `key` derived from mode + record identity.
A long-lived component instance shared across create/edit is how edit
contaminated create.
2. **Construct the model ONCE per mount**`useState(() => openXxxForm(snapshot))`.
NEVER `useMemo(() => open(...), [snapshot])`: the snapshot's identity depends
on live data (projects, hosts, preferences), and any background churn — e.g.
a scheduled run creating a workspace — would reconstruct the model and wipe
the user's in-progress input.
3. **Late data is an explicit model input, not a reconstruction.**
`applyProviderSnapshot(serverId, …)`, `applyProjectTargets(…)`,
`applyHosts(…)`. Adapters pipe identity changes into these with mechanical
effects. Input plumbing is fine; orchestration effects are not — the sheet
itself has zero `useEffect`/`useRef`, and that is the target for every form.
4. **Resolution is explicit model state, per host** (`idle | pending |
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
state you can render, not an effect race.
5. **Displays are owned state.** The selected option's label is captured at
selection/seed time (`setProject(value, display)`), never re-derived from a
live options list — list churn must not flicker or blank a selection.
6. **Disclosure is derived in the model** from user intent
(host → project → model → thinking/mode), so fields cannot pop in from
cache timing.
## Form kit
- Compose `Field` / `SelectField` / `FormTextInput` / `SegmentedControl` /
`Switch` from `components/ui/`. Geometry (heights, padding, radii, focus/hover
states) is owned by `components/ui/control-geometry.ts` — controls never
declare their own, and screens never nudge global component styles to align
a row.
- The form declares one size for all fields: `sm` on desktop, `md` compact
(`useIsCompactFormFactor`).
- Availability hierarchy: a field whose capability doesn't apply is **hidden**
(isolation on a non-git project — same gating as New Workspace), not rendered
disabled with an explanation. Disabled-with-a-reason `hint` is only for
transient states the user can resolve.
- Copy is opt-in and rare. No hint/subtext unless the maintainer approved the
exact string; validation errors are the exception. State a fact (like the
timezone) once — never in a preview line AND a helper line.
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
## Data gating
Aggregate hooks return a discriminated load state:
```ts
type AggregateLoadState<T> =
| { status: "connecting" } // an answer may still be pending
| { status: "loading" }
| { status: "loaded"; data: T[] };
```
Empty states are only typeable inside `loaded` — a fetch that "succeeded"
before hosts connected is `connecting`, not empty. Query keys carry real fetch
inputs (host set, connection statuses), never synthetic version counters.
## Anti-patterns (reject in review on sight)
- `useEffect` choreography impersonating construct/hydrate/resolve/destroy.
- One mounted form instance serving create and edit.
- `useMemo`-keyed model construction on live-data identity.
- Selected labels derived from live query lists.
- `isLoading`/`isEmpty` boolean bags where a load-state union belongs.
- Conditional mounting of hint/error rows that shifts layout (subtext renders
only when present, but the pattern for that lives in `Field`, not ad hoc).

View File

@@ -1 +1 @@
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
sha256-G88S68t7czYE7YtBIuRdkI85+GpVKEZUMZQtcnN73BA=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36170,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"@getpaseo/client": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/server": "0.1.104",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36421,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36678,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@getpaseo/client": "0.1.104",
"@getpaseo/highlight": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38582,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.4",
"version": "0.1.104",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

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

View File

@@ -3,6 +3,8 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
@@ -57,7 +59,7 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce }) => {
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
@@ -73,12 +75,20 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
const rawExtraHosts = localStorage.getItem(extraHostsKey);
const extraHosts = rawExtraHosts ? JSON.parse(rawExtraHosts) : [];
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
{
daemon: testDaemon,
preferences: createAgentPreferences,
seedNonce,
extraHostsKey: EXTRA_HOSTS_KEY,
},
);
await provide();

View File

@@ -0,0 +1,61 @@
import { expect, type Locator } from "@playwright/test";
interface TruncatedLabel {
text: string;
scrollWidth: number;
clientWidth: number;
}
export async function expectNoTruncation(locator: Locator): Promise<void> {
await expect(locator.first()).toBeVisible({ timeout: 30_000 });
const truncated = await locator.evaluateAll((elements): TruncatedLabel[] => {
function isVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0
);
}
function directText(element: HTMLElement): string {
return Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent ?? "")
.join("")
.trim();
}
const checked = new Set<HTMLElement>();
const failures: TruncatedLabel[] = [];
for (const root of elements) {
if (!(root instanceof HTMLElement) || !isVisible(root)) continue;
const candidates = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const candidate of candidates) {
if (checked.has(candidate) || !isVisible(candidate) || candidate.clientWidth <= 0) {
continue;
}
checked.add(candidate);
const text = directText(candidate);
if (!text) continue;
if (candidate.scrollWidth <= candidate.clientWidth) continue;
failures.push({
text,
scrollWidth: candidate.scrollWidth,
clientWidth: candidate.clientWidth,
});
}
}
return failures;
});
expect(truncated, "option labels should not be horizontally truncated").toEqual([]);
}

View File

@@ -5,10 +5,10 @@ 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";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export const FAKE_HOST_MODEL_ID = "fake-host-model";
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
type WebSocketMessage = string | Buffer;
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
@@ -20,6 +20,31 @@ export interface FakeScheduleHostWorkspace {
workspace: Record<string, unknown>;
}
interface FakeScheduleSummary {
id: string;
name: string | null;
prompt: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title?: string;
};
};
status: "active";
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -114,6 +139,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
ws.onMessage((message) => {
@@ -201,7 +227,7 @@ export async function installFakeScheduleHost(input: {
ws.send(
buildSessionMessage("schedule/list/response", {
requestId,
schedules: [],
schedules: input.schedules ?? [],
error: null,
}),
);
@@ -232,15 +258,24 @@ export async function addFakeScheduleHostAndReload(input: {
}
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);
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
nextRegistry.push(seededHost);
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
? JSON.parse(rawExtraHosts)
: [];
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
nextExtraHosts.push(seededHost);
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
extraHosts: EXTRA_HOSTS_KEY,
},
},
);

View File

@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();

View File

@@ -0,0 +1,86 @@
import { expect, type Locator } from "@playwright/test";
interface SettledOptions {
timeout?: number;
durationMs?: number;
intervalMs?: number;
heightTolerance?: number;
}
interface Sample {
text: string;
height: number;
}
const DEFAULT_DURATION_MS = 1_500;
const DEFAULT_INTERVAL_MS = 100;
const DEFAULT_HEIGHT_TOLERANCE = 1;
async function sample(locator: Locator): Promise<Sample> {
const [text, box] = await Promise.all([locator.textContent(), locator.boundingBox()]);
if (!box) {
throw new Error("Expected locator to keep a bounding box while sampling settledness.");
}
return { text: text ?? "", height: box.height };
}
async function collectSamples(locator: Locator, options?: SettledOptions): Promise<Sample[]> {
await expect(locator).toBeVisible({ timeout: options?.timeout ?? 30_000 });
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
const samples: Sample[] = [];
const deadline = Date.now() + durationMs;
while (Date.now() <= deadline || samples.length < 2) {
samples.push(await sample(locator));
await locator.page().waitForTimeout(intervalMs);
}
return samples;
}
function assertStableHeight(samples: Sample[], tolerance: number): void {
const heights = samples.map((entry) => entry.height);
const minHeight = Math.min(...heights);
const maxHeight = Math.max(...heights);
expect(maxHeight - minHeight, `height changed by more than ${tolerance}px`).toBeLessThanOrEqual(
tolerance,
);
}
function assertSettledText(samples: Sample[]): void {
let stableText: string | null = null;
let previousText: string | null = null;
for (const entry of samples) {
if (stableText !== null && entry.text !== stableText) {
throw new Error(
`Text changed after settling. Expected ${JSON.stringify(stableText)}, received ${JSON.stringify(entry.text)}.`,
);
}
if (previousText === entry.text) {
stableText = entry.text;
}
previousText = entry.text;
}
if (stableText === null) {
throw new Error("Text did not reach a stable value during the settledness window.");
}
}
export async function expectSettled(locator: Locator, options?: SettledOptions): Promise<void> {
const samples = await collectSamples(locator, options);
assertSettledText(samples);
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
}
export async function expectStableHeight(
locator: Locator,
options?: SettledOptions,
): Promise<void> {
const samples = await collectSamples(locator, options);
assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE);
}

View File

@@ -7,6 +7,7 @@ import {
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { escapeRegex } from "./helpers/regex";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
@@ -91,6 +92,10 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(modeControl).toBeVisible({ timeout: 30_000 });
await modeControl.click();
const popup = page.getByTestId("combobox-desktop-container").last();
await expect(popup).toBeVisible({ timeout: 10_000 });
await expectNoTruncation(popup);
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
@@ -105,6 +110,19 @@ async function selectMode(page: Page, label: string): Promise<void> {
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
}
async function expectThinkingOptionsFit(page: Page): Promise<void> {
const thinkingControl = page.getByTestId("agent-thinking-selector").first();
await expect(thinkingControl).toBeVisible({ timeout: 30_000 });
await thinkingControl.click();
const popup = page.getByTestId("combobox-desktop-container").last();
await expect(popup).toBeVisible({ timeout: 10_000 });
await expectNoTruncation(popup);
await page.keyboard.press("Escape");
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
@@ -158,6 +176,7 @@ test.describe("New workspace Codex mode preferences", () => {
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
timeout: 30_000,
});
await expectThinkingOptionsFit(page);
await selectMode(page, "Full access");
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");

View File

@@ -10,8 +10,8 @@ import {
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
} from "./helpers/new-workspace";
import { expectNoTruncation } from "./helpers/no-truncation";
import { createTempGitRepo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -63,7 +63,11 @@ test.describe("New workspace isolation memory", () => {
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await selectWorkspaceIsolation(page, "worktree");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);

View File

@@ -1,5 +1,15 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
FAKE_HOST_MODEL_ID,
FAKE_HOST_MODEL_LABEL,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectSettled, expectStableHeight } from "./helpers/settled";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleSeedClient {
@@ -56,6 +66,42 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
.catch(ignoreScheduleDeleteError);
}
type FakeScheduleHostSchedule = NonNullable<
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
>[number];
function buildFakeHostSchedule(input: {
id: string;
name: string;
cwd: string;
}): FakeScheduleHostSchedule {
const now = "2026-07-01T00:00:00.000Z";
return {
id: input.id,
name: input.name,
prompt: "Run on the secondary host.",
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: input.cwd,
model: FAKE_HOST_MODEL_ID,
modeId: "load-test",
title: input.name,
},
},
status: "active",
createdAt: now,
updatedAt: now,
nextRunAt: now,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
};
}
test.describe("Schedules", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
@@ -79,14 +125,114 @@ test.describe("Schedules", () => {
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toContainText(
workspace.projectDisplayName,
{ timeout: 30_000 },
);
await expect(page.getByTestId("schedule-model-trigger")).toContainText("Ten second stream", {
timeout: 30_000,
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
});
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
const scheduleId = "fake-host-schedule";
const scheduleName = "Secondary host schedule";
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
name: scheduleName,
cwd: String(fakeHost.workspace.workspaceDirectory),
}),
],
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
await page.reload();
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
});
test("create opens pristine after closing an edit form", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Pristine create ${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 row.click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("schedules-new").click();
await expect(formSheet).toBeVisible({ timeout: 10_000 });
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(/select project/i);
await expectSettled(projectTrigger);
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
});
});

View File

@@ -6,13 +6,27 @@ import {
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { escapeRegex } from "./helpers/regex";
import { expectNoTruncation } from "./helpers/no-truncation";
import { expectSettled, expectStableHeight } from "./helpers/settled";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 };
interface ScheduleListItem {
id: string;
name: string | null;
target: { type: string; config?: { cwd?: string } };
cadence?: { type: "cron"; expression: string };
target: {
type: string;
config?: {
cwd?: string;
archiveOnFinish?: boolean;
isolation?: "local" | "worktree";
};
};
}
interface ScheduleSeedClient {
@@ -24,7 +38,12 @@ 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();
const searchInput = page.getByTestId("model-search-input").first();
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await searchInput.fill(label);
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(popup).toHaveCount(0, { timeout: 30_000 });
}
@@ -40,6 +59,33 @@ async function deleteScheduleByName(workspace: SeededWorkspace, name: string): P
async function expectScheduleCreatedForProject(input: {
workspace: SeededWorkspace;
name: string;
cadenceExpression: 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,
cadence: expect.objectContaining({
type: "cron",
expression: input.cadenceExpression,
}),
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
}),
}),
}),
);
}
async function expectScheduleKnobs(input: {
workspace: SeededWorkspace;
name: string;
archiveOnFinish: boolean;
isolation: "local" | "worktree";
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
@@ -50,13 +96,54 @@ async function expectScheduleCreatedForProject(input: {
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
archiveOnFinish: input.archiveOnFinish,
isolation: input.isolation,
}),
}),
}),
);
}
async function openNewScheduleSheet(page: Page): Promise<void> {
await page.getByTestId("schedules-empty-new").click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
async function dismissScheduleSheetWithBackdrop(page: Page): Promise<void> {
const backdrop = bottomSheetBackdrop(page);
await expect(backdrop).toBeVisible({ timeout: 10_000 });
await expect(async () => {
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
async function expectScheduleSheetClosedAndStable(page: Page): Promise<void> {
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
await page.waitForTimeout(500);
await expect(formSheet).toHaveCount(0, { timeout: 1_000 });
}
async function findScheduleIdByName(workspace: SeededWorkspace, name: string): Promise<string> {
const client = workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === name);
if (!schedule) {
throw new Error(`Expected schedule named ${name} to exist`);
}
return schedule.id;
}
test.describe("Schedules project target", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
@@ -67,10 +154,30 @@ test.describe("Schedules project target", () => {
cleanupTasks.length = 0;
});
test("dismisses the new schedule sheet without reopening", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-sheet-dismiss-", git: false });
cleanupTasks.push(() => workspace.cleanup());
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await page.setViewportSize(MOBILE_SHEET_VIEWPORT);
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await openNewScheduleSheet(page);
await dismissScheduleSheetWithBackdrop(page);
await expectScheduleSheetClosedAndStable(page);
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await openNewScheduleSheet(page);
await page.getByRole("button", { name: "Cancel" }).click();
await expectScheduleSheetClosedAndStable(page);
});
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-", git: false });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Project schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
@@ -84,29 +191,84 @@ test.describe("Schedules project target", () => {
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await expect(page.getByTestId("schedule-host-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-project-trigger")).toBeVisible();
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
await expect(page.getByText(/Times are in/)).toHaveCount(0);
await expect(formSheet.getByText("Cron", { exact: true })).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,
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
await selectModelByLabel(page, "Ten second stream");
const modelTrigger = page.getByTestId("schedule-model-trigger");
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
await expectSettled(page.getByTestId("schedule-mode-trigger"));
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
await expect(page.getByText("Worktree isolation is available for git projects.")).toHaveCount(
0,
);
await page.getByTestId("schedule-cadence-preset-trigger").click();
await page.getByTestId("schedule-cadence-preset-daily-9").click();
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
await expect(page.getByTestId("cadence-cron-expression")).toHaveValue("0 9 * * *");
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 });
await expectScheduleCreatedForProject({
workspace,
name: scheduleName,
cadenceExpression: "0 9 * * *",
});
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 addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
await expect(hostFilterTrigger).toBeVisible({ timeout: 30_000 });
await hostFilterTrigger.click();
const hostFilterPopup = page.getByTestId("combobox-desktop-container").last();
await expect(hostFilterPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(hostFilterPopup);
await page.keyboard.press("Escape");
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, {
timeout: 5_000,
});
});
test("clears the selected model when the chosen project moves to another host", async ({
page,
}) => {
test("clears the selected project and model when the host changes", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const serverId = getServerId();
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
@@ -128,26 +290,126 @@ test.describe("Schedules project target", () => {
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await page.getByRole("button", { name: /select project/i }).click();
const hostTrigger = page.getByTestId("schedule-host-trigger");
const projectTrigger = page.getByTestId("schedule-project-trigger");
const modelTrigger = page.getByTestId("schedule-model-trigger");
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
await expect(projectTrigger).toHaveCount(0);
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await hostTrigger.click();
await page.getByTestId(`schedule-host-option-${serverId}`).click();
await expect(projectTrigger).toBeVisible();
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
await selectModelByLabel(page, "Ten second stream");
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
await expect(modelTrigger).toContainText("Ten second stream");
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toBeVisible();
await expectSettled(modeTrigger);
await page.getByRole("button", { name: /select project/i }).click();
await hostTrigger.click();
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
await expect(hostTrigger).toContainText("Fake host");
await expect(projectTrigger).toContainText(/select project/i);
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
await expectSettled(hostTrigger);
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
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 expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
await expectSettled(modelTrigger);
await expect(thinkingTrigger).toHaveCount(0);
await expect(modeTrigger).toHaveCount(0);
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();
});
test("creates and edits schedule isolation and archive cleanup knobs", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-knobs-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Knob schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await expect(page.getByTestId("schedules-empty-new")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
).toHaveCount(0);
await page.getByTestId("schedule-isolation-trigger").click();
await page.getByTestId("schedule-isolation-worktree").click();
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
await page.getByTestId("schedule-archive-on-finish-switch").click();
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Run with custom workspace cleanup.");
await page.getByTestId("schedule-cadence-preset-trigger").click();
await page.getByTestId("schedule-cadence-preset-every-hour").click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleKnobs({
workspace,
name: scheduleName,
archiveOnFinish: false,
isolation: "worktree",
});
const scheduleId = await findScheduleIdByName(workspace, scheduleName);
await page.getByTestId(`schedule-row-${scheduleId}`).click();
const formSheet = page.getByTestId("schedule-form-sheet");
await expect(formSheet).toBeVisible({ timeout: 10_000 });
await expectStableHeight(formSheet);
await expect(page.getByTestId("schedule-isolation-trigger")).toContainText("Worktree");
await expect(page.getByTestId("schedule-archive-on-finish-switch")).toHaveAttribute(
"aria-checked",
"false",
);
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
).toHaveCount(0);
await page.getByTestId("schedule-isolation-trigger").click();
await page.getByTestId("schedule-isolation-local").click();
await page.getByTestId("schedule-archive-on-finish-switch").click();
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleKnobs({
workspace,
name: scheduleName,
archiveOnFinish: true,
isolation: "local",
});
});
});

View File

@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Host");
await expectSettingsHeader(page, "Overview");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});

View File

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

View File

@@ -84,7 +84,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import { queryClient } from "@/data/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,

View File

@@ -1,7 +1,38 @@
import { useLocalSearchParams } from "expo-router";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
buildSettingsHostSectionRoute,
buildSettingsRoute,
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
function SettingsDaemonRedirect() {
const hosts = useHosts();
const localDaemon = useLocalDaemonServerIdState();
const bootstrapState = useHostRuntimeBootstrapState();
if (localDaemon.status === "loading") {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
if (
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId)
) {
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
}
return <Redirect href={buildSettingsRoute()} />;
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
if (rawSection === "daemon") {
return (
<HostRouteBootstrapBoundary>
<SettingsDaemonRedirect />
</HostRouteBootstrapBoundary>
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}

View File

@@ -1,8 +1,8 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -20,6 +20,7 @@ import {
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -220,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
...createControlGeometry(theme).controlFocusRingColor,
},
adaptiveInputText: {
color: theme.colors.foreground,
@@ -231,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
@@ -448,6 +450,7 @@ export interface AdaptiveModalSheetProps {
header: SheetHeader;
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
@@ -468,6 +471,7 @@ export function AdaptiveModalSheet({
header,
visible,
onClose,
onDismiss,
children,
footer,
snapPoints,
@@ -533,6 +537,20 @@ export function AdaptiveModalSheet({
isEnabled: isMobile,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
onDismiss?.();
}, [handleSheetDismiss, onDismiss]);
const notifyNativeModalDismiss = useCallback(() => {
if (nativeModalDismissNotifiedRef.current) {
return;
}
nativeModalDismissNotifiedRef.current = true;
onDismiss?.();
}, [onDismiss]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -545,12 +563,53 @@ export function AdaptiveModalSheet({
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
);
const desktopOverlayStyle = useMemo(
() => [
styles.desktopOverlay,
isWeb && {
opacity: isWebClosing ? 0 : 1,
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
transitionProperty: "opacity",
transitionTimingFunction: "ease",
},
],
[isWebClosing],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
useEffect(() => {
if (visible) {
nativeModalDismissNotifiedRef.current = false;
}
}, [visible]);
useEffect(() => {
if (!isWeb || isMobile) return;
if (visible) {
setShouldRenderWeb(true);
setIsWebClosing(false);
return;
}
if (!shouldRenderWeb) return;
setIsWebClosing(true);
const timeout = window.setTimeout(() => {
setShouldRenderWeb(false);
setIsWebClosing(false);
onDismiss?.();
}, WEB_EXIT_DURATION_MS);
return () => window.clearTimeout(timeout);
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
useEffect(() => {
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
const timeout = setTimeout(notifyNativeModalDismiss, 0);
return () => clearTimeout(timeout);
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
return (
<IsolatedBottomSheetModal
@@ -559,7 +618,7 @@ export function AdaptiveModalSheet({
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
@@ -614,7 +673,7 @@ export function AdaptiveModalSheet({
);
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<View style={desktopOverlayStyle} testID={testID}>
<Pressable
accessibilityLabel={t("common.actions.dismiss")}
style={ABSOLUTE_FILL_STYLE}
@@ -626,7 +685,7 @@ export function AdaptiveModalSheet({
// On web, use portal to overlay root for consistent stacking with toasts
if (isWeb && typeof document !== "undefined") {
if (!visible) return null;
if (!shouldRenderWeb) return null;
return createPortal(desktopContent, getOverlayRoot());
}
@@ -636,6 +695,7 @@ export function AdaptiveModalSheet({
animationType="fade"
visible={visible}
onRequestClose={onClose}
onDismiss={notifyNativeModalDismiss}
hardwareAccelerated
>
{desktopContent}

View File

@@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import {
formatAppDiagnosticHeader,
formatDiagnosticSection,
@@ -17,6 +16,7 @@ import {
formatServerInfoSection,
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
@@ -261,35 +261,6 @@ export function AppDiagnosticSheet({
);
}
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
try {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return {
status: "done",
sections: [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: logs.logPath || "unknown" },
{ label: "Error", value: status.error ?? "none" },
]),
[
"Desktop daemon log tail",
logs.contents ? indentBlock(logs.contents) : " No log lines found",
].join("\n"),
],
};
} catch (error) {
return {
status: "failed",
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
};
}
}
async function collectHostDiagnosticSections(
host: HostProfile,
snapshot: HostRuntimeSnapshot | null,
@@ -338,14 +309,6 @@ async function collectHostDiagnosticSections(
}
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -4,24 +4,39 @@ import {
View,
Text,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
Combobox,
ComboboxItem,
type ComboboxOption,
type ComboboxProps,
} from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
function noop() {}
@@ -47,22 +62,67 @@ function drillDownRowStyle({
pressed && styles.drillDownRowPressed,
];
}
import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
const DESKTOP_MODEL_ROW_HEIGHT = 40;
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
const ThemedStar = withUnistyles(Star);
const foregroundMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
});
const favoriteStarMapping =
(isFavorite: boolean, hovered: boolean) =>
(theme: Theme): { color: string; fill: string } => {
const favoriteColor = theme.colors.palette.amber[500];
if (isFavorite) {
return { color: favoriteColor, fill: favoriteColor };
}
return {
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
fill: "transparent",
};
};
type ProviderGlyphTone = "muted" | "foreground";
function ProviderGlyph({
provider,
size,
tone = "muted",
}: {
provider: string;
size: number;
tone?: ProviderGlyphTone;
}) {
const Icon = getProviderIcon(provider);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
}
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
}
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
@@ -89,6 +149,8 @@ interface CombinedModelSelectorProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
/**
* 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
@@ -149,9 +211,7 @@ function ModelRow({
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
@@ -162,8 +222,8 @@ function ModelRow({
);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
[row.provider],
);
const trailingSlot = useMemo(
() =>
@@ -178,32 +238,10 @@ function ModelRow({
}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => {
let starColor: string;
if (isFavorite) starColor = theme.colors.palette.amber[500];
else if (hovered) starColor = theme.colors.foregroundMuted;
else starColor = theme.colors.border;
return (
<Star
size={16}
color={starColor}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
);
}}
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
</Pressable>
) : null,
[
onToggleFavorite,
handleToggleFavorite,
isFavorite,
row.provider,
row.modelId,
theme.colors.palette.amber,
theme.colors.foregroundMuted,
theme.colors.border,
t,
],
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
);
return (
@@ -305,9 +343,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
}
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const ProvIcon = getProviderIcon(provider.id);
const selection = provider.modelSelection;
const handlePress = useCallback(() => {
@@ -327,18 +363,16 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
} else if (selection.kind === "loading") {
stateNode = (
<View style={styles.rowStateInline}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<View style={styles.rowSpinner}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
</View>
);
@@ -346,11 +380,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
<Text style={styles.drillDownText}>{provider.label}</Text>
<View style={styles.drillDownTrailing}>
{stateNode}
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
</Pressable>
);
@@ -446,14 +480,13 @@ function ProviderErrorEmptyState({
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleRetry = useCallback(() => {
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
return (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{message}</Text>
{onRetryProvider ? (
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
@@ -477,7 +510,6 @@ function SelectorContent({
onRetryProvider,
isRetryingProvider,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
@@ -501,7 +533,7 @@ function SelectorContent({
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
</View>
);
@@ -514,11 +546,9 @@ function SelectorContent({
if (drillSelection.kind === "loading") {
return (
<View style={styles.emptyState}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<View style={styles.rowSpinner}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
</View>
);
@@ -585,9 +615,10 @@ export function CombinedModelSelector({
isRetryingProvider = false,
disabled = false,
serverId = null,
desktopPlacement,
desktopMinWidth,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
@@ -643,7 +674,6 @@ export function CombinedModelSelector({
);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
return resolveSelectedModelLabel({
@@ -750,7 +780,6 @@ export function CombinedModelSelector({
if (view.kind === "all") {
return { title: t("modelSelector.title") };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openProviderSettings}
@@ -763,17 +792,12 @@ export function CombinedModelSelector({
})}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
size={theme.iconSize.sm}
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
/>
<HeaderSettingsIcon disabled={!serverId} />
</Pressable>
);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : undefined,
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: headerActions,
search: {
@@ -789,15 +813,10 @@ export function CombinedModelSelector({
singleProviderView,
serverId,
openProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,
handleSearchQueryChange,
searchResetKey,
t,
theme.iconSize.md,
theme.iconSize.sm,
theme.colors.foreground,
]);
return (
@@ -835,8 +854,8 @@ export function CombinedModelSelector({
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
{hasSelectedProvider ? (
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
@@ -850,8 +869,8 @@ export function CombinedModelSelector({
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
desktopFixedHeight={desktopFixedHeight}
header={sheetHeader}
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
@@ -872,7 +891,7 @@ export function CombinedModelSelector({
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
</View>
)}
@@ -1044,4 +1063,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
providerIconMuted: {
color: theme.colors.foregroundMuted,
},
providerIconForeground: {
color: theme.colors.foreground,
},
}));

View File

@@ -1,5 +1,6 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,

View File

@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
export {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
};
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
@@ -115,6 +121,12 @@ export function HostPickerOption({
);
}
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
add: "Add host",
all: "All hosts",
enableBuiltInDaemon: "Enable built-in daemon",
};
function SystemHostPickerOption({
active,
selected,
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
kind: "add" | "all" | "enableBuiltInDaemon";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
@@ -158,11 +170,13 @@ export interface HostPickerProps {
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
includeEnableBuiltInDaemon?: boolean;
onEnableBuiltInDaemon?: () => void;
showActiveConnection?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
@@ -179,11 +193,13 @@ export function HostPicker({
includeAllHost,
includeAddHost,
onAddHost,
includeEnableBuiltInDaemon,
onEnableBuiltInDaemon,
showActiveConnection,
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopPlacement = "bottom-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,
@@ -199,8 +215,13 @@ export function HostPicker({
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
if (includeEnableBuiltInDaemon)
hostOptions.push({
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
label: "Enable built-in daemon",
});
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
@@ -208,12 +229,14 @@ export function HostPicker({
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
onEnableBuiltInDaemon?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
@@ -246,6 +269,11 @@ export function HostPicker({
/>
);
}
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
return (
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
);
}
return (
<HostPickerOption
serverId={option.id}

View File

@@ -385,6 +385,7 @@ function SidebarHostPicker({
showActiveConnection
onOpenHostSettings={onOpenHostSettings}
searchable
desktopPlacement="top-start"
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}

View File

@@ -1,366 +1,140 @@
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
describeCron,
everyMsToParts,
type IntervalUnit,
partsToEveryMs,
validateCron,
} from "@/utils/schedule-format";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetDisplay,
resolveCronPresetId,
} from "@/schedules/schedule-cadence-options";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { describeCron, validateCron } from "@/utils/schedule-format";
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`;
}
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
}
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
const mode = value.type;
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
testID: `schedule-cadence-preset-${option.id}`,
}));
// 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,
);
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error || !expression) {
return null;
}
return describeCron({ type: "cron", expression, timezone }) ?? expression;
}
function buildCronCadence(expression: string, timezone: string): CronCadence {
return { type: "cron", expression, timezone };
}
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
const [cronText, setCronText] = useState(() => normalizedValue.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 cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
const trimmedCron = cronText.trim();
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
const effectiveError = error ?? localCronError;
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
const currentCadence = useMemo<CronCadence>(
() => buildCronCadence(trimmedCron, cronTimeZone),
[cronTimeZone, trimmedCron],
);
const selectedPresetId = resolveCronPresetId(currentCadence);
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
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) {
const handlePresetChange = useCallback(
(presetId: string) => {
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
if (!preset) {
return;
}
if (nextMode === "every") {
emitInterval(parsedIntervalValue, intervalUnit);
} else {
emitCron(cronText.trim() || lastCronExpression.current);
}
setCronText(preset.expression);
bumpFieldResetKey();
onChange(buildCronCadence(preset.expression, cronTimeZone));
},
[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],
[cronTimeZone, onChange],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
emitCron(text.trim());
onChange(buildCronCadence(text.trim(), cronTimeZone));
},
[emitCron],
[cronTimeZone, onChange],
);
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>;
let feedback: ReactNode = null;
if (effectiveError) {
feedback = <Text style={styles.error}>{effectiveError}</Text>;
} else if (preview) {
feedback = <Text style={styles.preview}>{preview}</Text>;
}
return (
<View style={styles.container}>
<SegmentedControl
size="sm"
value={mode}
onValueChange={handleModeChange}
options={MODE_OPTIONS}
style={styles.modeControl}
testID="cadence-mode"
/>
<Field label="Cadence">
<View style={styles.stack}>
<SelectField
label="Cadence"
value={selectedPresetId === "custom" ? null : selectedPresetId}
selectedDisplay={selectedPresetDisplay}
options={PRESET_OPTIONS}
onChange={handlePresetChange}
placeholder="Select cadence"
emptyText="No cadences found"
searchable={false}
title="Cadence"
size={size}
triggerTestID="schedule-cadence-preset-trigger"
field={false}
/>
{mode === "every" ? (
<View style={styles.section}>
<View style={styles.intervalRow}>
<AdaptiveTextInput
testID="cadence-interval-value"
accessibilityLabel="Interval value"
initialValue={intervalValueText}
resetKey={`cadence-interval-${fieldResetKey}`}
value={intervalValueText}
onChangeText={handleIntervalValueChange}
keyboardType="number-pad"
style={styles.intervalInput}
/>
<SegmentedControl
size="sm"
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
testID="cadence-interval-unit"
/>
</View>
<Text style={styles.preview}>{intervalPreview}</Text>
</View>
) : (
<View style={styles.section}>
<View style={styles.presetRow}>
{CRON_PRESETS.map((preset) => (
<CronPresetChip
key={preset.expression}
label={preset.label}
expression={preset.expression}
isSelected={trimmedCron === preset.expression}
onSelect={handlePresetPress}
/>
))}
</View>
<AdaptiveTextInput
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{cronFeedback}
<Text style={styles.hint}>Times are in UTC</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
function CronPresetChip({
label,
expression,
isSelected,
onSelect,
}: {
label: string;
expression: string;
isSelected: boolean;
onSelect: (expression: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(expression);
}, [onSelect, expression]);
const chipStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.chip,
isSelected && styles.chipSelected,
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
],
[isSelected],
);
const labelStyle = useMemo(
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
[isSelected],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
<FormTextInput
size={size}
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}
/>
{feedback}
</View>
</Field>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
stack: {
gap: theme.spacing[3],
},
section: {
gap: theme.spacing[3],
},
intervalRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
intervalInput: {
width: 88,
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
modeControl: {
alignSelf: "flex-start",
},
presetRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
chip: {
minHeight: 32,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hint: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,16 @@ import type {
TextStyle,
ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
buttonIconSize,
createControlGeometry,
type ButtonControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "xs" | "sm" | "md" | "lg";
type ButtonSize = ButtonControlSize;
type LeftIcon =
| ReactElement
@@ -27,85 +33,130 @@ type LeftIcon =
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { xs: 12, sm: 14, md: 16, lg: 20 };
interface ButtonIconProps {
loading: boolean;
leftIcon?: LeftIcon;
iconSize: number;
iconColor: string;
}
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
},
md: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
xs: {
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
lg: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
},
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
secondary: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.surface3,
},
outline: {
backgroundColor: "transparent",
borderColor: theme.colors.borderAccent,
},
ghost: {
backgroundColor: "transparent",
borderColor: "transparent",
},
destructive: {
backgroundColor: theme.colors.destructive,
borderColor: theme.colors.destructive,
},
pressed: {
opacity: 0.85,
},
disabled: {
opacity: theme.opacity[50],
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
textXs: {
fontSize: theme.fontSize.xs,
},
textDefault: {
color: theme.colors.accentForeground,
},
textDestructive: {
color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={iconColor} />
</View>
);
}
if (!leftIcon) return null;
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
if (
typeof leftIcon === "function" &&
!leftIcon.prototype?.isReactComponent &&
leftIcon.length > 0
) {
return <View>{(leftIcon as (color: string) => ReactElement)(iconColor)}</View>;
}
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return (
<View>
<Icon color={iconColor} size={iconSize} />
</View>
);
}
const ThemedButtonIcon = withUnistyles(ButtonIcon);
const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const foregroundMutedIconMapping = (theme: Theme) => ({
iconColor: theme.colors.foregroundMuted,
});
const accentForegroundIconMapping = (theme: Theme) => ({
iconColor: theme.colors.accentForeground,
});
const destructiveForegroundIconMapping = (theme: Theme) => ({
iconColor: theme.colors.destructiveForeground,
});
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
base: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
},
md: {
...geometry.buttonMd,
},
xs: {
...geometry.buttonXs,
},
sm: {
...geometry.buttonSm,
},
lg: {
...geometry.buttonLg,
},
default: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
secondary: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.surface3,
},
outline: {
backgroundColor: "transparent",
borderColor: theme.colors.borderAccent,
},
ghost: {
backgroundColor: "transparent",
borderColor: "transparent",
},
destructive: {
backgroundColor: theme.colors.destructive,
borderColor: theme.colors.destructive,
},
pressed: {
opacity: 0.85,
},
disabled: {
opacity: theme.opacity[50],
},
text: {
color: theme.colors.foreground,
...geometry.buttonText,
fontWeight: theme.fontWeight.normal,
},
textXs: {
...geometry.buttonTextXs,
},
textDefault: {
color: theme.colors.accentForeground,
},
textDestructive: {
color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
};
});
export function Button({
children,
@@ -118,6 +169,7 @@ export function Button({
disabled,
loading = false,
accessibilityRole,
accessibilityState: accessibilityStateProp,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
@@ -131,7 +183,6 @@ export function Button({
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
@@ -188,55 +239,21 @@ export function Button({
);
const accessibilityState = useMemo(
() => ({ disabled: isDisabled, busy: loading }),
[isDisabled, loading],
() => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }),
[accessibilityStateProp, isDisabled, loading],
);
function resolveIconColor(): string {
function resolveIconMapping() {
if (variant === "default") {
return theme.colors.accentForeground;
return accentForegroundIconMapping;
}
if (variant === "destructive") {
return destructiveForegroundIconMapping;
}
if (variant === "ghost") {
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping;
}
return theme.colors.foreground;
}
function renderIcon() {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={resolveIconColor()} />
</View>
);
}
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = resolveIconColor();
const iconSize = ICON_SIZE[size];
// Render function
if (
typeof leftIcon === "function" &&
!leftIcon.prototype?.isReactComponent &&
leftIcon.length > 0
) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return (
<View>
<Icon color={color} size={iconSize} />
</View>
);
return foregroundIconMapping;
}
return (
@@ -249,7 +266,12 @@ export function Button({
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{renderIcon()}
<ThemedButtonIcon
loading={loading}
leftIcon={leftIcon}
iconSize={buttonIconSize[size]}
uniProps={resolveIconMapping()}
/>
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
{trailing}
</Pressable>

View File

@@ -0,0 +1,58 @@
import type { ViewStyle } from "react-native";
import { describe, expect, it } from "vitest";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
function buildWidthStyle(input: {
desktopMinWidth?: number;
referenceWidth: number | null;
}): Pick<ViewStyle, "width" | "minWidth" | "maxWidth"> {
const [frameStyle] = buildDesktopFrameStyle({
desktopMinWidth: input.desktopMinWidth,
referenceWidth: input.referenceWidth,
desktopFixedHeight: undefined,
desktopPositionStyle: { left: 0, top: 0 },
shouldHideDesktopContent: false,
availableHeight: undefined,
}) as ViewStyle[];
return {
width: frameStyle.width,
minWidth: frameStyle.minWidth,
maxWidth: frameStyle.maxWidth,
};
}
describe("buildDesktopFrameStyle", () => {
it("lets a narrow trigger grow to the default desktop ceiling", () => {
expect(buildWidthStyle({ referenceWidth: 120 })).toEqual({
width: undefined,
minWidth: 120,
maxWidth: 400,
});
});
it("keeps a wide trigger from being capped below its own width", () => {
expect(buildWidthStyle({ referenceWidth: 470 })).toEqual({
width: undefined,
minWidth: 470,
maxWidth: 470,
});
});
it("uses desktopMinWidth as an explicit floor raiser", () => {
expect(buildWidthStyle({ desktopMinWidth: 360, referenceWidth: 120 })).toEqual({
width: undefined,
minWidth: 360,
maxWidth: 400,
});
});
it("keeps the trigger as the floor when it is wider than desktopMinWidth", () => {
expect(buildWidthStyle({ desktopMinWidth: 240, referenceWidth: 300 })).toEqual({
width: undefined,
minWidth: 300,
maxWidth: 400,
});
});
});

View File

@@ -0,0 +1,42 @@
import type { StyleProp, ViewStyle } from "react-native";
export interface DesktopFrameStyleInput {
desktopMinWidth: number | undefined;
referenceWidth: number | null;
desktopFixedHeight: number | undefined;
desktopPositionStyle: StyleProp<ViewStyle>;
shouldHideDesktopContent: boolean;
availableHeight: number | undefined;
}
export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
shouldHideDesktopContent,
availableHeight,
} = input;
const fixedHeightStyle =
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null;
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
const availableHeightStyle =
typeof availableHeight === "number"
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
return [
{
position: "absolute" as const,
minWidth: floor,
maxWidth: Math.max(400, floor),
},
fixedHeightStyle,
desktopPositionStyle,
hiddenStyle,
availableHeightStyle,
];
}

View File

@@ -1,9 +1,18 @@
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import {
forwardRef,
useCallback,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from "react";
import {
Pressable,
View,
type NativeSyntheticEvent,
type PressableProps,
type PressableStateCallbackType,
type TargetedEvent,
type ViewStyle,
type StyleProp,
} from "react-native";
@@ -20,6 +29,7 @@ const chevronColorMapping = (theme: Theme) => ({
interface TriggerState {
pressed: boolean;
hovered: boolean;
focused: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
@@ -36,23 +46,45 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
}
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
{ children, chevron, style, block = false, ...props },
{ children, chevron, style, block = false, onFocus, onBlur, ...props },
ref,
): ReactElement {
const [focused, setFocused] = useState(false);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered });
return style({ pressed, hovered, focused });
}
return style;
},
[style],
[focused, style],
);
const handleFocus = useCallback(
(event: NativeSyntheticEvent<TargetedEvent>) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback(
(event: NativeSyntheticEvent<TargetedEvent>) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
return (
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
<Pressable
ref={ref}
collapsable={false}
style={pressableStyle}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
>
<View style={rowStyle}>
{children}
{chevron !== null &&

View File

@@ -61,10 +61,14 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
const IS_WEB = isWeb;
export type ComboboxOption = ComboboxOptionModel;
export type ComboboxDesktopPlacement = "top-start" | "bottom-start";
export interface ComboboxProps {
options: ComboboxOption[];
@@ -99,7 +103,7 @@ export interface ComboboxProps {
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
desktopPlacement?: ComboboxDesktopPlacement;
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
@@ -381,7 +385,7 @@ function OptionsList({
interface DesktopPositionInput {
isDesktopAboveSearch: boolean;
isMobile: boolean;
desktopPlacement: "top-start" | "bottom-start";
desktopPlacement: ComboboxDesktopPlacement;
referenceTop: number | null;
referenceLeft: number | null;
referenceAtOrigin: boolean;
@@ -656,7 +660,7 @@ interface DesktopResetSetters {
function useDesktopPositionReset(
isOpen: boolean,
isMobile: boolean,
desktopPlacement: "top-start" | "bottom-start",
desktopPlacement: ComboboxDesktopPlacement,
update: () => unknown,
setters: DesktopResetSetters,
) {
@@ -850,46 +854,6 @@ function buildFloatingMiddleware(input: FloatingMiddlewareInput) {
];
}
interface DesktopContainerStyleInput {
desktopMinWidth: number | undefined;
referenceWidth: number | null;
desktopFixedHeight: number | undefined;
desktopPositionStyle: DesktopPositionResult["desktopPositionStyle"];
shouldHideDesktopContent: boolean;
availableHeight: number | undefined;
}
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
shouldHideDesktopContent,
availableHeight,
} = input;
const fixedHeightStyle =
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null;
const hiddenStyle = shouldHideDesktopContent ? { opacity: 0 } : null;
const availableHeightStyle =
typeof availableHeight === "number"
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: Math.max(400, desktopMinWidth ?? 0),
},
fixedHeightStyle,
desktopPositionStyle,
hiddenStyle,
availableHeightStyle,
];
}
function isDesktopKey(key: string): key is DesktopKey {
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
}
@@ -1255,7 +1219,7 @@ export function Combobox({
presentation,
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPlacement = "bottom-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
desktopFixedHeight,

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
createControlGeometry,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
const theme = {
borderRadius: {
md: 6,
lg: 8,
xl: 12,
},
borderWidth: {
1: 1,
},
colors: {
accent: "#20744A",
borderAccent: "#2F3534",
},
fontSize: {
xs: 12,
sm: 14,
base: 16,
},
opacity: {
50: 0.5,
},
spacing: {
0: 0,
3: 12,
4: 16,
6: 24,
},
} as unknown as Theme;
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
expect(geometry.controlRest).toMatchObject({
borderWidth: 1,
borderColor: "transparent",
outlineColor: "transparent",
outlineWidth: 0,
});
});
it("uses the shared hover border and active focus ring values", () => {
const geometry = createControlGeometry(theme);
expect(geometry.controlHover).toEqual({
borderColor: "#2F3534",
});
expect(geometry.controlActive).toEqual({
borderColor: "#2F3534",
outlineColor: "#20744A",
outlineOffset: 1,
outlineStyle: "solid",
outlineWidth: 2,
});
});
it("resolves disabled, focus, open, pressed, and hover into one interaction phase", () => {
expect(getControlInteractionPhase({ disabled: true, focused: true })).toBe("rest");
expect(getControlInteractionPhase({ focused: true })).toBe("active");
expect(getControlInteractionPhase({ open: true })).toBe("active");
expect(getControlInteractionPhase({ pressed: true })).toBe("active");
expect(getControlInteractionPhase({ hovered: true })).toBe("hover");
expect(getControlInteractionPhase({})).toBe("rest");
});
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextMd.fontSize).toBe(16);
expect(geometry.fieldTextMd.lineHeight).toBe(22);
expect(geometry.formTextInputSm.fontSize).toBe(14);
expect(geometry.formTextInputSm.lineHeight).toBe(20);
expect(geometry.formTextInputMd.fontSize).toBe(16);
expect(geometry.formTextInputMd.lineHeight).toBe(22);
});
it("derives field padding from line height without changing the control height", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
geometry.fieldControlSm.minHeight,
);
expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
geometry.fieldControlMd.minHeight,
);
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("subtracts segmented control inset from the nested segment radius", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
});
});

View File

@@ -0,0 +1,224 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
hovered?: boolean;
focused?: boolean;
pressed?: boolean;
open?: boolean;
active?: boolean;
disabled?: boolean;
}
export interface ControlInteractionStyleMap {
controlRest: StyleProp<ViewStyle>;
controlHover: StyleProp<ViewStyle>;
controlActive: StyleProp<ViewStyle>;
controlDisabled?: StyleProp<ViewStyle>;
}
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
const SWITCH_TRACK_HEIGHT = 20;
const SWITCH_THUMB_SIZE = 16;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
const controlHeights = {
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
export const buttonIconSize: Record<ButtonControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
lg: ICON_SIZE.lg,
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
export const switchGeometry = {
trackWidth: SWITCH_TRACK_WIDTH,
trackHeight: SWITCH_TRACK_HEIGHT,
thumbSize: SWITCH_THUMB_SIZE,
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
function nestedRadius(containerRadius: number, inset: number): number {
return Math.max(0, containerRadius - inset);
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
return (controlHeight - lineHeight) / 2;
}
export function getControlInteractionPhase(
state: ControlInteractionState,
): ControlInteractionPhase {
if (state.disabled) {
return "rest";
}
if (state.active || state.focused || state.open || state.pressed) {
return "active";
}
if (state.hovered) {
return "hover";
}
return "rest";
}
export function resolveControlInteractionStyles(
styles: ControlInteractionStyleMap,
state: ControlInteractionState,
): StyleProp<ViewStyle> {
const phase = getControlInteractionPhase(state);
return [
styles.controlRest,
phase === "hover" ? styles.controlHover : null,
phase === "active" ? styles.controlActive : null,
state.disabled ? styles.controlDisabled : null,
];
}
export function createControlGeometry(theme: Theme) {
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(controlHeights.compact, fieldTextSmLineHeight),
borderRadius: theme.borderRadius.md,
};
const fieldControlMd = {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(controlHeights.field, fieldTextMdLineHeight),
borderRadius: theme.borderRadius.lg,
};
const fieldTextSm = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextSmLineHeight,
};
const fieldTextMd = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const segmentedContainerSmRadius = theme.borderRadius.md;
const segmentedContainerMdRadius = theme.borderRadius.lg;
const switchControl = {
minHeight: controlHeights.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
} satisfies { minHeight: number; justifyContent: "center" };
return {
buttonXs: {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
buttonSm: {
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
buttonMd: {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
},
buttonLg: {
minHeight: controlHeights.field,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
},
buttonText: {
fontSize: theme.fontSize.sm,
},
buttonTextXs: {
fontSize: theme.fontSize.xs,
},
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
},
formTextInputMd: {
...fieldControlMd,
...fieldTextMd,
},
formTextInput: {
...fieldControlMd,
...fieldTextMd,
},
fieldControlSm,
fieldControlMd,
fieldTextSm,
fieldTextMd,
controlRest: {
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
},
controlHover: {
borderColor: theme.colors.borderAccent,
},
controlActive: {
borderColor: theme.colors.borderAccent,
outlineColor: theme.colors.accent,
outlineOffset: CONTROL_FOCUS_RING_OFFSET,
outlineStyle: "solid" as const,
outlineWidth: CONTROL_FOCUS_RING_WIDTH,
},
controlFocusRingColor: {
outlineColor: theme.colors.accent,
},
controlDisabled: {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
},
};
}

View File

@@ -1,9 +1,30 @@
import { forwardRef, useMemo, type ReactNode } from "react";
import { Text, View, type TextInput } from "react-native";
import {
forwardRef,
useCallback,
useMemo,
useState,
type ForwardedRef,
type ReactNode,
} from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
View,
type PressableStateCallbackType,
type TextInput,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
interface FormFieldProps {
interface FieldProps {
label: string;
children: ReactNode;
hint?: string;
@@ -11,20 +32,20 @@ interface FormFieldProps {
testID?: string;
}
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
export function Field({ label, children, hint, error, testID }: FieldProps) {
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
const hintOrError = useMemo(() => {
const subtext = useMemo(() => {
if (error) {
return (
<Text style={styles.errorText} testID={errorTestID}>
<Text numberOfLines={1} style={styles.errorText} testID={errorTestID}>
{error}
</Text>
);
}
if (hint) {
return (
<Text style={styles.hintText} testID={hintTestID}>
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
@@ -36,17 +57,167 @@ export function FormField({ label, children, hint, error, testID }: FormFieldPro
<View style={styles.container} testID={testID}>
<Text style={styles.label}>{label}</Text>
{children}
{hintOrError}
{subtext}
</View>
);
}
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
{ style, ...props },
type FormTextInputProps = AdaptiveTextInputProps & {
size?: FieldControlSize;
};
type FlatFormTextInputStyle = ViewStyle & TextStyle;
interface SplitFormTextInputStyle {
chromeStyle?: ViewStyle;
inputStyle?: TextStyle;
}
function splitFormTextInputStyle(style: AdaptiveTextInputProps["style"]): SplitFormTextInputStyle {
const flattened = RNStyleSheet.flatten(style) as FlatFormTextInputStyle | undefined;
if (!flattened) {
return {};
}
const {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
...chromeStyle
} = flattened;
const inputStyle: TextStyle = {
color,
fontFamily,
fontSize,
fontStyle,
fontVariant,
fontWeight,
includeFontPadding,
letterSpacing,
lineHeight,
textAlign,
textAlignVertical,
textDecorationColor,
textDecorationLine,
textDecorationStyle,
textShadowColor,
textShadowOffset,
textShadowRadius,
textTransform,
writingDirection,
};
return {
chromeStyle: stripUnistylesMetadata(chromeStyle),
inputStyle: stripUnistylesMetadata(inputStyle),
};
}
function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
const cleanStyle: Record<string, unknown> = {};
for (const [key, value] of Object.entries(style as Record<string, unknown>)) {
if (key.startsWith("unistyles_") || value === undefined) {
continue;
}
cleanStyle[key] = value;
}
return cleanStyle as TStyle;
}
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
if (typeof forwardedRef === "function") {
forwardedRef(node);
return;
}
if (forwardedRef) {
forwardedRef.current = node;
}
}
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
{ size = "md", style, onFocus, onBlur, editable, ...props },
ref,
) {
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
const [focused, setFocused] = useState(false);
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: TextInput | null) => {
assignTextInputRef(ref, node);
},
[ref],
);
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const inputStyle = useMemo(
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
[inputSizeStyle, splitStyle.inputStyle],
) as AdaptiveTextInputProps["style"];
const chromeStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
formInputStyles.chrome,
chromeSizeStyle,
resolveControlInteractionStyles(
{
controlRest: formInputStyles.controlRest,
controlHover: formInputStyles.controlHover,
controlActive: formInputStyles.controlActive,
controlDisabled: formInputStyles.controlDisabled,
},
{
hovered,
focused,
disabled: isDisabled,
},
),
splitStyle.chromeStyle,
],
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
);
return (
<Pressable disabled={isDisabled} style={chromeStyle}>
<AdaptiveTextInput
ref={setInputRef}
editable={editable}
{...props}
onBlur={handleBlur}
onFocus={handleFocus}
style={inputStyle}
/>
</Pressable>
);
});
const styles = StyleSheet.create((theme) => ({
@@ -56,27 +227,59 @@ const styles = StyleSheet.create((theme) => ({
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: Math.round(theme.fontSize.xs * 1.4),
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: Math.round(theme.fontSize.xs * 1.4),
},
}));
const formInputStyles = StyleSheet.create((theme) => ({
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,
},
}));
const formInputStyles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeSm: {
...geometry.fieldControlSm,
},
chromeMd: {
...geometry.fieldControlMd,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
input: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
paddingHorizontal: 0,
paddingVertical: 0,
outlineColor: "transparent",
outlineWidth: 0,
},
inputSm: {
...geometry.fieldTextSm,
},
inputMd: {
...geometry.fieldTextMd,
},
};
});

View File

@@ -1,9 +1,13 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
createControlGeometry,
segmentedIconSize,
type SegmentedControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
@@ -25,6 +29,21 @@ interface SegmentedControlProps<T extends string> {
testID?: string;
}
interface SegmentIconProps {
icon: SegmentedControlIconRenderer;
iconSize: number;
iconColor: string;
}
function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
return <View style={styles.iconContainer}>{icon({ color: iconColor, size: iconSize })}</View>;
}
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
export function SegmentedControl<T extends string>({
options,
value,
@@ -34,11 +53,10 @@ export function SegmentedControl<T extends string>({
style,
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
const iconSize = segmentedIconSize[size];
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
@@ -49,14 +67,12 @@ export function SegmentedControl<T extends string>({
<View style={containerStyle} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<SegmentItem
key={option.value}
option={option}
isSelected={isSelected}
iconColor={iconColor}
iconSize={iconSize}
hideLabels={hideLabels}
segmentSizeStyle={segmentSizeStyle}
@@ -73,7 +89,6 @@ export function SegmentedControl<T extends string>({
function SegmentItem<T extends string>({
option,
isSelected,
iconColor,
iconSize,
hideLabels,
segmentSizeStyle,
@@ -83,7 +98,6 @@ function SegmentItem<T extends string>({
}: {
option: SegmentedControlOption<T>;
isSelected: boolean;
iconColor: string;
iconSize: number;
hideLabels: boolean;
segmentSizeStyle: StyleProp<ViewStyle>;
@@ -119,15 +133,18 @@ function SegmentItem<T extends string>({
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
aria-selected={isSelected}
disabled={option.disabled}
testID={option.testID}
onPress={handlePress}
style={pressableStyle}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
<ThemedSegmentIcon
icon={option.icon}
iconSize={iconSize}
uniProps={isSelected ? selectedIconMapping : mutedIconMapping}
/>
) : null}
{hideLabels ? null : (
<Text style={labelStyle} numberOfLines={1}>
@@ -138,68 +155,68 @@ function SegmentItem<T extends string>({
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.lg,
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
segmentSelected: {
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
},
iconContainer: {
alignItems: "center",
justifyContent: "center",
},
label: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelSm: {
fontSize: theme.fontSize.sm,
},
labelMd: {
fontSize: theme.fontSize.base,
},
labelSelected: {
color: theme.colors.foreground,
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
container: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
gap: 2,
},
containerSm: {
...geometry.segmentedContainerSm,
},
containerMd: {
...geometry.segmentedContainerMd,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
gap: theme.spacing[1],
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
segmentMd: {
...geometry.segmentedSegmentMd,
},
segmentSelected: {
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
},
iconContainer: {
alignItems: "center",
justifyContent: "center",
},
label: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelSm: {
...geometry.segmentedLabelSm,
},
labelMd: {
...geometry.segmentedLabelMd,
},
labelSelected: {
color: theme.colors.foreground,
},
};
});

View File

@@ -0,0 +1,394 @@
import { useCallback, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import {
Pressable,
Text,
View,
type NativeSyntheticEvent,
type PressableStateCallbackType,
type TargetedEvent,
} from "react-native";
import { ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface SelectFieldDisplay {
label: string;
description?: string;
}
export interface SelectFieldOption<TValue> {
id: string;
value: TValue;
label: string;
description?: string;
kind?: ComboboxOption["kind"];
testID?: string;
}
export interface SelectFieldRenderOptionInput<TValue> {
option: SelectFieldOption<TValue>;
selected: boolean;
active: boolean;
onPress: () => void;
}
export interface SelectFieldProps<TValue> {
label: string;
value: TValue | null;
selectedDisplay: SelectFieldDisplay | null;
options: SelectFieldOption<TValue>[];
onChange: (value: TValue, display: SelectFieldDisplay) => void;
placeholder: string;
emptyText: string;
loading?: boolean;
disabled?: boolean;
hint?: string;
error?: string | null;
searchable?: boolean;
searchPlaceholder?: string;
title?: string;
size?: FieldControlSize;
getValueKey?: (value: TValue) => string;
renderOption?: (input: SelectFieldRenderOptionInput<TValue>) => ReactElement;
triggerLeading?: ReactNode;
field?: boolean;
testID?: string;
triggerTestID?: string;
}
export interface SelectFieldTriggerProps {
display?: SelectFieldDisplay | null;
label?: string;
isPlaceholder?: boolean;
placeholder: string;
hovered?: boolean;
focused?: boolean;
active?: boolean;
disabled?: boolean;
loading?: boolean;
leading?: ReactNode;
size?: FieldControlSize;
testID?: string;
}
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function getSelectedOptionId<TValue>(
options: readonly SelectFieldOption<TValue>[],
value: TValue | null,
getValueKey: ((value: TValue) => string) | undefined,
): string {
if (value === null) {
return "";
}
if (getValueKey) {
const selectedKey = getValueKey(value);
return options.find((option) => getValueKey(option.value) === selectedKey)?.id ?? "";
}
return options.find((option) => Object.is(option.value, value))?.id ?? "";
}
function useVisibleSelectOptions<TValue>(
options: SelectFieldOption<TValue>[],
loading: boolean,
): SelectFieldOption<TValue>[] {
const previousOptionsRef = useRef<SelectFieldOption<TValue>[]>(options);
if (options.length > 0 || !loading) {
previousOptionsRef.current = options;
}
if (loading && options.length === 0) {
return previousOptionsRef.current;
}
return options;
}
export function SelectFieldTrigger({
display,
label: explicitLabel,
isPlaceholder: explicitIsPlaceholder,
placeholder,
hovered = false,
focused = false,
active = false,
disabled = false,
loading = false,
leading,
size = "md",
testID,
}: SelectFieldTriggerProps): ReactElement {
const sizeStyle = size === "sm" ? styles.triggerSm : styles.triggerMd;
const textSizeStyle = size === "sm" ? styles.triggerTextSm : styles.triggerTextMd;
const triggerStyle = useMemo(
() => [
styles.trigger,
sizeStyle,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{ hovered, focused, active, disabled },
),
],
[active, disabled, focused, hovered, sizeStyle],
);
const label = explicitLabel ?? display?.label ?? placeholder;
const isPlaceholder = explicitIsPlaceholder ?? display == null;
const textStyle = useMemo(
() => [isPlaceholder ? styles.placeholderText : styles.triggerText, textSizeStyle],
[isPlaceholder, textSizeStyle],
);
return (
<View pointerEvents="none" style={triggerStyle} testID={testID}>
{leading}
<Text style={textStyle} numberOfLines={1}>
{label}
</Text>
{loading ? (
<View style={styles.spinnerSlot}>
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</View>
) : null}
<ThemedChevronDown size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
</View>
);
}
export function SelectField<TValue>({
label,
value,
selectedDisplay,
options,
onChange,
placeholder,
emptyText,
loading = false,
disabled = false,
hint,
error,
searchable = false,
searchPlaceholder,
title,
size = "md",
getValueKey,
renderOption,
triggerLeading,
field = true,
testID,
triggerTestID,
}: SelectFieldProps<TValue>): ReactElement {
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [triggerFocused, setTriggerFocused] = useState(false);
const visibleOptions = useVisibleSelectOptions(options, loading);
const selectedOptionId = useMemo(
() => getSelectedOptionId(visibleOptions, value, getValueKey),
[getValueKey, value, visibleOptions],
);
const comboboxOptions = useMemo<ComboboxOption[]>(
() =>
visibleOptions.map((option) => ({
id: option.id,
label: option.label,
description: option.description,
kind: option.kind,
})),
[visibleOptions],
);
const optionById = useMemo(
() => new Map(visibleOptions.map((option) => [option.id, option])),
[visibleOptions],
);
const handleSelect = useCallback(
(id: string) => {
const option = optionById.get(id);
if (!option) {
return;
}
onChange(option.value, { label: option.label, description: option.description });
setOpen(false);
},
[onChange, optionById],
);
const handlePress = useCallback(() => {
if (disabled) {
return;
}
setOpen((current) => !current);
}, [disabled]);
const handleTriggerFocus = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
setTriggerFocused(true);
}, []);
const handleTriggerBlur = useCallback((_event: NativeSyntheticEvent<TargetedEvent>) => {
setTriggerFocused(false);
}, []);
const renderComboboxOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const selectOption = optionById.get(option.id);
if (!selectOption) {
return (
<ComboboxItem
label={option.label}
description={option.description}
kind={option.kind}
selected={selected}
active={active}
onPress={onPress}
/>
);
}
if (renderOption) {
return renderOption({ option: selectOption, selected, active, onPress });
}
return (
<ComboboxItem
testID={selectOption.testID}
label={selectOption.label}
description={selectOption.description}
kind={selectOption.kind}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[optionById, renderOption],
);
const displayLabel = selectedDisplay?.label ?? placeholder;
const fieldHint = selectedDisplay?.description ?? hint;
const control = (
<>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handlePress}
onFocus={handleTriggerFocus}
onBlur={handleTriggerBlur}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={`${label} (${displayLabel})`}
testID={triggerTestID}
>
{({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => (
<SelectFieldTrigger
display={selectedDisplay}
placeholder={placeholder}
hovered={Boolean(hovered)}
focused={triggerFocused}
active={pressed || open}
disabled={disabled}
loading={loading}
leading={triggerLeading}
size={size}
/>
)}
</Pressable>
</View>
<Combobox
options={comboboxOptions}
value={selectedOptionId}
onSelect={handleSelect}
searchable={searchable}
searchPlaceholder={searchPlaceholder}
emptyText={loading && visibleOptions.length === 0 ? "Loading..." : emptyText}
title={title ?? label}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
renderOption={renderComboboxOption}
/>
</>
);
if (!field) {
return <View testID={testID}>{control}</View>;
}
return (
<Field label={label} hint={fieldHint} error={error} testID={testID}>
{control}
</Field>
);
}
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
},
triggerSm: {
...geometry.fieldControlSm,
},
triggerMd: {
...geometry.fieldControlMd,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
triggerText: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
},
placeholderText: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
},
triggerTextSm: {
...geometry.fieldTextSm,
},
triggerTextMd: {
...geometry.fieldTextMd,
},
spinnerSlot: {
flexShrink: 0,
width: ICON_SIZE.md,
alignItems: "center",
},
};
});

View File

@@ -7,10 +7,15 @@ import { Switch } from "./switch";
const { theme } = vi.hoisted(() => ({
theme: {
opacity: { 50: 0.5 },
spacing: { 0: 0, 3: 12, 4: 16, 6: 24 },
fontSize: { xs: 12, sm: 14, base: 16 },
borderRadius: { md: 6, lg: 8, xl: 12 },
borderWidth: { 1: 1 },
colors: {
surface3: "#333",
accent: "#0a84ff",
accentForeground: "#fff",
borderAccent: "#555",
palette: { white: "#fff" },
},
},
@@ -32,6 +37,9 @@ vi.mock("react-native-reanimated", () => ({
}));
vi.mock("react-native", () => ({
Platform: {
select: (values: Record<string, unknown>) => values.web ?? values.default,
},
Pressable: ({
"aria-checked": ariaChecked,
accessibilityLabel,
@@ -71,7 +79,13 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
withUnistyles:
(
Component: React.ComponentType<Record<string, unknown>>,
mapping?: (theme: unknown) => object,
) =>
(props: Record<string, unknown>) =>
React.createElement(Component, { ...props, ...mapping?.(theme) }),
}));
describe("Switch", () => {

View File

@@ -12,7 +12,9 @@ import Animated, {
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { createControlGeometry, switchGeometry } from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
interface SwitchProps {
value: boolean;
@@ -23,11 +25,54 @@ interface SwitchProps {
style?: StyleProp<ViewStyle>;
}
const TRACK = { width: 34, height: 20 };
const THUMB = 16;
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
interface SwitchTrackProps {
value: boolean;
trackOffColor: string;
trackOnColor: string;
thumbOffColor: string;
thumbOnColor: string;
}
function SwitchTrack({
value,
trackOffColor,
trackOnColor,
thumbOffColor,
thumbOnColor,
}: SwitchTrackProps) {
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
const trackAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [trackOffColor, trackOnColor]),
}));
const thumbAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], [thumbOffColor, thumbOnColor]),
transform: [{ translateX: progress.value * switchGeometry.thumbTravel }],
}));
const trackStyle = useMemo(() => [styles.switchTrack, trackAnimatedStyle], [trackAnimatedStyle]);
const thumbStyle = useMemo(
() => [styles.switchThumb, styles.thumb, thumbAnimatedStyle],
[thumbAnimatedStyle],
);
return (
<Animated.View style={trackStyle}>
<Animated.View style={thumbStyle} />
</Animated.View>
);
}
const ThemedSwitchTrack = withUnistyles(SwitchTrack, (theme: Theme) => ({
trackOffColor: theme.colors.surface3,
trackOnColor: theme.colors.accent,
thumbOffColor: theme.colors.palette.white,
thumbOnColor: theme.colors.accentForeground,
}));
export function Switch({
value,
onValueChange,
@@ -36,31 +81,6 @@ export function Switch({
testID,
style,
}: SwitchProps) {
const { theme } = useUnistyles();
const track = TRACK;
const thumb = THUMB;
const padding = (track.height - thumb) / 2;
const thumbTravel = track.width - thumb - padding * 2;
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
const trackAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.surface3, theme.colors.accent],
),
}));
const thumbAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.palette.white, theme.colors.accentForeground],
),
transform: [{ translateX: progress.value * thumbTravel }],
}));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
@@ -72,30 +92,9 @@ export function Switch({
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [disabled ? styles.disabled : null, style],
() => [styles.switchControl, disabled ? styles.disabled : null, style],
[disabled, style],
);
const trackStyle = useMemo(
() => [
styles.track,
{
width: track.width,
height: track.height,
borderRadius: track.height / 2,
padding,
},
trackAnimatedStyle,
],
[track.width, track.height, padding, trackAnimatedStyle],
);
const thumbStyle = useMemo(
() => [
styles.thumb,
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
thumbAnimatedStyle,
],
[thumb, thumbAnimatedStyle],
);
return (
<Pressable
@@ -109,25 +108,39 @@ export function Switch({
testID={testID}
style={pressableStyle}
>
<Animated.View style={trackStyle}>
<Animated.View style={thumbStyle} />
</Animated.View>
<ThemedSwitchTrack value={value} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
justifyContent: "center",
},
thumb: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 1,
elevation: 2,
},
disabled: {
opacity: theme.opacity[50],
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
switchControl: {
...geometry.switchControl,
},
switchTrack: {
width: switchGeometry.trackWidth,
height: switchGeometry.trackHeight,
borderRadius: switchGeometry.trackHeight / 2,
padding: (switchGeometry.trackHeight - switchGeometry.thumbSize) / 2,
justifyContent: "center",
},
switchThumb: {
width: switchGeometry.thumbSize,
height: switchGeometry.thumbSize,
borderRadius: switchGeometry.thumbSize / 2,
},
thumb: {
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 1,
elevation: 2,
},
disabled: {
opacity: theme.opacity[50],
},
};
});

View File

@@ -805,6 +805,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
desktopPlacement="top-start"
desktopMinWidth={360}
/>
</View>
</TooltipTrigger>
@@ -1012,6 +1014,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider}
renderTrigger={renderModelTrigger}
serverId={modelSelectorServerId}
desktopPlacement="top-start"
desktopMinWidth={360}
/>
) : null}
@@ -1684,6 +1688,8 @@ export function DraftAgentControls({
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
desktopPlacement="top-start"
desktopMinWidth={360}
/>
{selectedProvider ? (
<ControlledAgentControls

View File

@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.37",
version: "3.0.38",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.37", "--acp"],
command: ["npx", "-y", "cline@3.0.38", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.116.0",
version: "2.117.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.117.2", "--acp"],
},
{
id: "codewhale",
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.19",
version: "0.2.22",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
command: ["npx", "-y", "dimcode@0.2.22", "acp"],
},
{
id: "dirac",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.164.0",
version: "0.164.1",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.164.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.164.1", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.8.3",
version: "0.9.2",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.8.3", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.2", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.24",
version: "1.1.25",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.24", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.25", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.37",
version: "1.0.39",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.39", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.6",
version: "0.19.7",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.7", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -0,0 +1,3 @@
export function daemonConfigQueryKey(serverId: string | null) {
return ["daemon-config", serverId] as const;
}

View File

@@ -5,7 +5,7 @@ import {
providersSnapshotQueryKey,
providersSnapshotQueryRoot,
providersSnapshotRequestOptions,
} from "./providers-snapshot-query";
} from "@/data/providers-snapshot";
describe("providers snapshot query scope", () => {
it("normalizes blank cwd values to the home scope", () => {

View File

@@ -0,0 +1,518 @@
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
import {
checkoutDiffPushRoute,
invalidateServerDataQueriesAfterReconnect,
mountServerDataPushRouter,
workspaceTerminalsPushRoute,
} from "@/data/push-router";
type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>;
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type RouterMessage =
| ProvidersSnapshotUpdateMessage
| CheckoutDiffUpdateMessage
| SubscribeCheckoutDiffResponseMessage
| StatusMessage
| TerminalsChangedMessage;
type RouterMessageType = RouterMessage["type"];
type RouterHandler = (message: RouterMessage) => void;
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
const daemonConfig: MutableDaemonConfig = {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
};
function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}): {
client: RouterClient;
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
checkout_diff_update: [],
subscribe_checkout_diff_response: [],
status: [],
terminals_changed: [],
};
const subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
handler: (message: Extract<RouterMessage, { type: K }>) => void,
): () => void {
const routerHandler: RouterHandler = (message) => {
if (message.type === type) {
handler(message as Extract<RouterMessage, { type: K }>);
}
};
handlers[type].push(routerHandler);
return () => {
handlers[type] = handlers[type].filter((candidate) => candidate !== routerHandler);
};
}
function emit<K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>): void {
for (const handler of handlers[message.type]) {
handler(message);
}
}
return {
client: {
on,
async subscribeCheckoutDiff(cwd, compare, requestOptions) {
subscribeCheckoutDiffCalls.push({
cwd,
compare,
subscriptionId: requestOptions.subscriptionId,
});
if (config.rejectCheckoutDiffSubscribe) {
throw new Error("subscribe failed");
}
return {
subscriptionId: requestOptions.subscriptionId,
cwd,
files: [],
error: null,
requestId: requestOptions.requestId ?? "subscribe-checkout-diff",
};
},
unsubscribeCheckoutDiff(subscriptionId) {
unsubscribeCheckoutDiffCalls.push(subscriptionId);
},
subscribeTerminals(subscription) {
subscribeTerminalCalls.push(subscription);
},
unsubscribeTerminals(subscription) {
unsubscribeTerminalCalls.push(subscription);
},
},
emit,
subscribeCheckoutDiffCalls,
unsubscribeCheckoutDiffCalls,
subscribeTerminalCalls,
unsubscribeTerminalCalls,
};
}
function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
return {
type: "providers_snapshot_update",
payload: {
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
generatedAt,
},
};
}
describe("server data push router", () => {
it("routes provider snapshot and daemon config payloads until detached", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
fake.emit({
type: "status",
payload: { status: "daemon_config_changed", config: daemonConfig },
});
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
generatedAt: "2026-01-01T00:00:00.000Z",
requestId: "providers_snapshot_update",
});
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
unmount();
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({
entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }],
generatedAt: "2026-01-01T00:00:00.000Z",
requestId: "providers_snapshot_update",
});
});
it("subscribes active checkout diff queries and writes matching diff events", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: checkoutDiffPushRoute({
enabled: true,
serverId,
subscriptionId,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeCheckoutDiffCalls).toEqual([
{
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
subscriptionId,
},
]);
fake.emit({
type: "subscribe_checkout_diff_response",
payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" },
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
requestId: "diff-1",
});
fake.emit({
type: "checkout_diff_update",
payload: { subscriptionId, cwd, files: [], error: null },
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
files: [],
error: null,
requestId: `subscription:${subscriptionId}`,
});
unsubscribeObserver();
expect(fake.unsubscribeCheckoutDiffCalls).toEqual([subscriptionId]);
unmount();
});
it("does not retry failed subscriptions on unrelated cache events", async () => {
const queryClient = new QueryClient();
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: checkoutDiffPushRoute({
enabled: true,
serverId,
subscriptionId,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
await Promise.resolve();
await Promise.resolve();
queryClient.setQueryData(["unrelated"], "value");
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
unsubscribeObserver();
unmount();
});
it("subscribes active terminal queries and filters terminal pushes by workspace", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const workspaceId = "workspace-a";
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
const observer = new QueryObserver(queryClient, {
queryKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: workspaceTerminalsPushRoute({
enabled: true,
serverId,
cwd,
workspaceId,
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
fake.emit({
type: "terminals_changed",
payload: {
cwd,
terminals: [
{ id: "terminal-a", name: "Main", workspaceId },
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
],
},
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
requestId: expect.stringMatching(/^terminals-changed-/),
});
unsubscribeObserver();
expect(fake.unsubscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
unmount();
});
it("re-sends active push subscriptions after reconnect", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const workspaceId = "workspace-a";
const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`;
const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
const checkoutDiffObserver = new QueryObserver(queryClient, {
queryKey: checkoutDiffKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: checkoutDiffPushRoute({
enabled: true,
serverId,
subscriptionId: checkoutDiffSubscriptionId,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
const terminalObserver = new QueryObserver(queryClient, {
queryKey: terminalKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: workspaceTerminalsPushRoute({
enabled: true,
serverId,
cwd,
workspaceId,
}),
});
const unsubscribeCheckoutDiffObserver = checkoutDiffObserver.subscribe(() => undefined);
const unsubscribeTerminalObserver = terminalObserver.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
const plainCheckoutDiffObserver = new QueryObserver(queryClient, {
queryKey: checkoutDiffKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
});
const plainTerminalObserver = new QueryObserver(queryClient, {
queryKey: terminalKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
});
const unsubscribePlainCheckoutDiffObserver = plainCheckoutDiffObserver.subscribe(
() => undefined,
);
const unsubscribePlainTerminalObserver = plainTerminalObserver.subscribe(() => undefined);
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
expect(fake.subscribeCheckoutDiffCalls).toEqual([
{
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
subscriptionId: checkoutDiffSubscriptionId,
},
{
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
subscriptionId: checkoutDiffSubscriptionId,
},
]);
expect(fake.subscribeTerminalCalls).toEqual([
{ cwd, workspaceId },
{ cwd, workspaceId },
]);
fake.emit({
type: "terminals_changed",
payload: {
cwd,
terminals: [
{ id: "terminal-a", name: "Main", workspaceId },
{ id: "terminal-b", name: "Sibling", workspaceId: "workspace-b" },
],
},
});
expect(queryClient.getQueryData(terminalKey)).toEqual({
cwd,
terminals: [{ id: "terminal-a", name: "Main", workspaceId }],
requestId: expect.stringMatching(/^terminals-changed-/),
});
unsubscribePlainCheckoutDiffObserver();
unsubscribePlainTerminalObserver();
unsubscribeCheckoutDiffObserver();
unsubscribeTerminalObserver();
unmount();
});
it("routes terminal pushes after another observer attaches without push metadata", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const workspaceId = "workspace-a";
const queryKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
const pushObserver = new QueryObserver(queryClient, {
queryKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
meta: workspaceTerminalsPushRoute({
enabled: true,
serverId,
cwd,
workspaceId,
}),
});
const unsubscribePushObserver = pushObserver.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]);
const plainObserver = new QueryObserver(queryClient, {
queryKey,
queryFn: skipToken,
enabled: true,
gcTime: Infinity,
staleTime: Infinity,
});
const unsubscribePlainObserver = plainObserver.subscribe(() => undefined);
fake.emit({
type: "terminals_changed",
payload: {
cwd,
terminals: [
{
id: "terminal-a",
name: "Main",
workspaceId,
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
},
],
},
});
expect(queryClient.getQueryData(queryKey)).toEqual({
cwd,
terminals: [
{
id: "terminal-a",
name: "Main",
workspaceId,
activity: { state: "idle", attentionReason: "needs_input", changedAt: 1 },
},
],
requestId: expect.stringMatching(/^terminals-changed-/),
});
expect(fake.unsubscribeTerminalCalls).toEqual([]);
unsubscribePlainObserver();
unsubscribePushObserver();
unmount();
});
it("invalidates only the reconnect-repair scopes for one server", () => {
const queryClient = new QueryClient();
const serverId = "server-1";
const otherServerId = "server-2";
const providerKey = providersSnapshotQueryKey(serverId);
const daemonConfigKey = daemonConfigQueryKey(serverId);
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
queryClient.setQueryData(daemonConfigKey, daemonConfig);
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
queryClient.setQueryData(otherProviderKey, {
entries: [],
generatedAt: "now",
requestId: "other",
});
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
});
});

View File

@@ -0,0 +1,754 @@
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
import type {
ListTerminalsResponse,
MutableDaemonConfig,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>;
type CheckoutDiffUpdateMessage = Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type ServerDataEventType =
| "providers_snapshot_update"
| "checkout_diff_update"
| "subscribe_checkout_diff_response"
| "status"
| "terminals_changed";
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
}
interface CheckoutDiffRoute {
domain: "checkoutDiff";
enabled: boolean;
serverId: string;
subscriptionId: string;
cwd: string;
compare: CheckoutDiffCompare;
}
interface WorkspaceTerminalsRoute {
domain: "workspaceTerminals";
enabled: boolean;
serverId: string;
cwd: string;
workspaceId?: string;
}
type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute;
export interface ServerDataQueryMeta extends Record<string, unknown> {
serverData: ServerDataRoute;
}
export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
interface ServerDataPushClient {
on<TType extends ServerDataEventType>(
type: TType,
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
): () => void;
subscribeCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
options: { subscriptionId: string; requestId?: string },
): Promise<CheckoutDiffResponsePayload>;
unsubscribeCheckoutDiff(subscriptionId: string): void;
subscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
}
interface PushRouterInput {
client: ServerDataPushClient;
queryClient: QueryClient;
serverId: string;
}
interface ActiveServerDataSubscriptions {
checkoutDiff: Map<string, CheckoutDiffRoute>;
workspaceTerminals: Map<string, WorkspaceTerminalsRoute>;
}
interface ReconnectRepairPolicy {
domain: string;
invalidate(input: { queryClient: QueryClient; serverId: string }): void;
}
const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
{
domain: "providersSnapshot",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
},
},
{
domain: "daemonConfig",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
},
},
{
domain: "checkoutDiff",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({
predicate: (query) => isQueryForServer(query.queryKey, "checkoutDiff", serverId),
});
},
},
{
domain: "workspaceTerminals",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({
predicate: (query) => isQueryForServer(query.queryKey, "terminals", serverId),
});
},
},
];
const reconnectSubscriptionRepairsByServerId = new Map<string, Set<() => void>>();
export function checkoutDiffPushRoute(input: {
enabled: boolean;
serverId: string;
subscriptionId: string;
cwd: string;
compare: CheckoutDiffCompare;
}): ServerDataQueryMeta {
return {
serverData: {
domain: "checkoutDiff",
enabled: input.enabled,
serverId: input.serverId,
subscriptionId: input.subscriptionId,
cwd: input.cwd,
compare: input.compare,
},
};
}
export function workspaceTerminalsPushRoute(input: {
enabled: boolean;
serverId: string;
cwd: string;
workspaceId?: string;
}): ServerDataQueryMeta {
return {
serverData: {
domain: "workspaceTerminals",
enabled: input.enabled,
serverId: input.serverId,
cwd: input.cwd,
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
},
};
}
export function invalidateServerDataQueriesAfterReconnect(input: {
queryClient: QueryClient;
serverId: string;
}): void {
for (const policy of RECONNECT_REPAIR_POLICIES) {
policy.invalidate(input);
}
for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ??
[]) {
repairSubscriptions();
}
}
export function applyProvidersSnapshotUpdate(input: {
serverId: string;
queryClient: QueryClient;
message: ProvidersSnapshotUpdate;
}): void {
if (input.message.type !== "providers_snapshot_update") {
return;
}
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
input.queryClient.setQueryData(queryKey, {
entries: input.message.payload.entries,
generatedAt: input.message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
exact: false,
});
}
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
const activeCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
const activeTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
let disposed = false;
function reconcileSubscriptions(
fallbackActive: ActiveServerDataSubscriptions = {
checkoutDiff: activeCheckoutDiffSubscriptions,
workspaceTerminals: activeTerminalSubscriptions,
},
): void {
if (disposed) {
return;
}
const desiredCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRoute>();
const desiredTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
for (const query of input.queryClient.getQueryCache().getAll()) {
const route = getActiveServerDataRoute(query, input.serverId, {
checkoutDiff: fallbackActive.checkoutDiff,
workspaceTerminals: fallbackActive.workspaceTerminals,
});
if (!route) {
continue;
}
if (route.domain === "checkoutDiff") {
desiredCheckoutDiffSubscriptions.set(route.subscriptionId, route);
continue;
}
desiredTerminalSubscriptions.set(workspaceTerminalSubscriptionKey(route), route);
}
reconcileCheckoutDiffSubscriptions({
active: activeCheckoutDiffSubscriptions,
client: input.client,
desired: desiredCheckoutDiffSubscriptions,
serverId: input.serverId,
});
reconcileTerminalSubscriptions({
active: activeTerminalSubscriptions,
client: input.client,
desired: desiredTerminalSubscriptions,
});
}
function resetSubscriptionsAfterReconnect(): void {
const fallbackActive = {
checkoutDiff: new Map(activeCheckoutDiffSubscriptions),
workspaceTerminals: new Map(activeTerminalSubscriptions),
};
activeCheckoutDiffSubscriptions.clear();
activeTerminalSubscriptions.clear();
reconcileSubscriptions(fallbackActive);
}
const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => {
if (
!shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, {
checkoutDiff: activeCheckoutDiffSubscriptions,
workspaceTerminals: activeTerminalSubscriptions,
})
) {
return;
}
reconcileSubscriptions();
});
const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => {
applyProvidersSnapshotUpdate({
queryClient: input.queryClient,
serverId: input.serverId,
message,
});
});
const unsubscribeDaemonConfig = input.client.on("status", (message) => {
applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message });
});
const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => {
applyCheckoutDiffUpdate({
activeCheckoutDiffSubscriptions,
queryClient: input.queryClient,
serverId: input.serverId,
message,
});
});
const unsubscribeCheckoutDiffResponse = input.client.on(
"subscribe_checkout_diff_response",
(message) => {
applyCheckoutDiffSubscribeResponse({
activeCheckoutDiffSubscriptions,
queryClient: input.queryClient,
serverId: input.serverId,
message,
});
},
);
const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => {
applyTerminalsChanged({
activeCheckoutDiffSubscriptions,
activeTerminalSubscriptions,
queryClient: input.queryClient,
serverId: input.serverId,
message,
});
});
let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId);
if (!reconnectSubscriptionRepairs) {
reconnectSubscriptionRepairs = new Set();
reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs);
}
reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect);
reconcileSubscriptions();
return () => {
disposed = true;
reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect);
if (reconnectSubscriptionRepairs.size === 0) {
reconnectSubscriptionRepairsByServerId.delete(input.serverId);
}
unsubscribeQueryCache();
unsubscribeProviders();
unsubscribeDaemonConfig();
unsubscribeCheckoutDiffUpdate();
unsubscribeCheckoutDiffResponse();
unsubscribeTerminalsChanged();
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) {
unsubscribeCheckoutDiff(input.client, subscriptionId);
}
activeCheckoutDiffSubscriptions.clear();
for (const route of activeTerminalSubscriptions.values()) {
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route));
}
activeTerminalSubscriptions.clear();
};
}
function reconcileCheckoutDiffSubscriptions(input: {
active: Map<string, CheckoutDiffRoute>;
client: ServerDataPushClient;
desired: Map<string, CheckoutDiffRoute>;
serverId: string;
}): void {
for (const [subscriptionId, current] of input.active) {
const desired = input.desired.get(subscriptionId);
if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
continue;
}
unsubscribeCheckoutDiff(input.client, subscriptionId);
input.active.delete(subscriptionId);
}
for (const [subscriptionId, desired] of input.desired) {
if (input.active.has(subscriptionId)) {
continue;
}
input.active.set(subscriptionId, desired);
void input.client
.subscribeCheckoutDiff(desired.cwd, desired.compare, {
subscriptionId,
requestId: `push-router:${input.serverId}:${subscriptionId}`,
})
.catch((error) => {
if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) {
input.active.delete(subscriptionId);
}
console.error("[server-data] subscribeCheckoutDiff failed", {
serverId: input.serverId,
cwd: desired.cwd,
error,
});
});
}
}
function reconcileTerminalSubscriptions(input: {
active: Map<string, WorkspaceTerminalsRoute>;
client: ServerDataPushClient;
desired: Map<string, WorkspaceTerminalsRoute>;
}): void {
for (const [key, current] of input.active) {
const desired = input.desired.get(key);
if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) {
continue;
}
input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current));
input.active.delete(key);
}
for (const [key, desired] of input.desired) {
if (input.active.has(key)) {
continue;
}
input.active.set(key, desired);
input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired));
}
}
function applyDaemonConfigStatus(input: {
queryClient: QueryClient;
serverId: string;
message: StatusMessage;
}): void {
const payload = input.message.payload;
if (!isDaemonConfigChangedPayload(payload)) {
return;
}
input.queryClient.setQueryData<MutableDaemonConfig>(
daemonConfigQueryKey(input.serverId),
payload.config,
);
}
function applyCheckoutDiffUpdate(input: {
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
queryClient: QueryClient;
serverId: string;
message: CheckoutDiffUpdateMessage;
}): void {
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: input.message.payload.subscriptionId,
payload: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
requestId: `subscription:${input.message.payload.subscriptionId}`,
},
});
}
function applyCheckoutDiffSubscribeResponse(input: {
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
queryClient: QueryClient;
serverId: string;
message: SubscribeCheckoutDiffResponseMessage;
}): void {
setCheckoutDiffPayload({
activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions,
queryClient: input.queryClient,
serverId: input.serverId,
subscriptionId: input.message.payload.subscriptionId,
payload: {
cwd: input.message.payload.cwd,
files: orderCheckoutDiffFiles(input.message.payload.files),
error: input.message.payload.error,
requestId: input.message.payload.requestId,
},
});
}
function setCheckoutDiffPayload(input: {
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
queryClient: QueryClient;
serverId: string;
subscriptionId: string;
payload: CheckoutDiffCachePayload;
}): void {
for (const query of input.queryClient.getQueryCache().getAll()) {
const route =
getServerDataRoute(query) ??
getActiveCheckoutDiffRouteForQueryKey({
active: input.activeCheckoutDiffSubscriptions,
queryKey: query.queryKey,
serverId: input.serverId,
});
if (
!route ||
route.domain !== "checkoutDiff" ||
route.serverId !== input.serverId ||
route.subscriptionId !== input.subscriptionId
) {
continue;
}
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
}
}
function applyTerminalsChanged(input: {
activeCheckoutDiffSubscriptions: Map<string, CheckoutDiffRoute>;
activeTerminalSubscriptions: Map<string, WorkspaceTerminalsRoute>;
queryClient: QueryClient;
serverId: string;
message: TerminalsChangedMessage;
}): void {
for (const query of input.queryClient.getQueryCache().getAll()) {
const route = getActiveServerDataRoute(query, input.serverId, {
checkoutDiff: input.activeCheckoutDiffSubscriptions,
workspaceTerminals: input.activeTerminalSubscriptions,
});
if (
!route ||
route.domain !== "workspaceTerminals" ||
route.cwd !== input.message.payload.cwd
) {
continue;
}
const matchingTerminals = input.message.payload.terminals.filter(
(terminal) => terminal.workspaceId === route.workspaceId,
);
input.queryClient.setQueryData<ListTerminalsPayload>(query.queryKey, (current) => ({
cwd: input.message.payload.cwd,
terminals: matchingTerminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
}
}
function getActiveServerDataRoute(
query: Query,
serverId: string,
active: ActiveServerDataSubscriptions,
): ServerDataRoute | null {
if (query.getObserversCount() === 0) {
return null;
}
const route = getServerDataRoute(query);
if (route) {
return route.enabled && route.serverId === serverId ? route : null;
}
return getActiveRouteForQueryKey({
active,
queryKey: query.queryKey,
serverId,
});
}
function getActiveRouteForQueryKey(input: {
active: ActiveServerDataSubscriptions;
queryKey: QueryKey;
serverId: string;
}): ServerDataRoute | null {
return (
getActiveTerminalRouteForQueryKey({
active: input.active.workspaceTerminals,
queryKey: input.queryKey,
serverId: input.serverId,
}) ??
getActiveCheckoutDiffRouteForQueryKey({
active: input.active.checkoutDiff,
queryKey: input.queryKey,
serverId: input.serverId,
})
);
}
function getActiveTerminalRouteForQueryKey(input: {
active: Map<string, WorkspaceTerminalsRoute>;
queryKey: QueryKey;
serverId: string;
}): WorkspaceTerminalsRoute | null {
if (!isQueryForServer(input.queryKey, "terminals", input.serverId)) {
return null;
}
const cwd = input.queryKey[2];
const workspaceId = input.queryKey[3];
if (
typeof cwd !== "string" ||
(workspaceId !== undefined && workspaceId !== null && typeof workspaceId !== "string")
) {
return null;
}
return input.active.get(`${cwd}\u0000${workspaceId ?? ""}`) ?? null;
}
function getActiveCheckoutDiffRouteForQueryKey(input: {
active: Map<string, CheckoutDiffRoute>;
queryKey: QueryKey;
serverId: string;
}): CheckoutDiffRoute | null {
if (!isQueryForServer(input.queryKey, "checkoutDiff", input.serverId)) {
return null;
}
for (const route of input.active.values()) {
if (isCheckoutDiffQueryKeyForRoute(input.queryKey, route)) {
return route;
}
}
return null;
}
function shouldReconcileSubscriptionsForCacheEvent(
event: QueryCacheNotifyEvent,
serverId: string,
active: ActiveServerDataSubscriptions,
): boolean {
if (!canEventChangeDesiredSubscriptions(event.type)) {
return false;
}
const route = getServerDataRoute(event.query);
if (route?.serverId === serverId) {
return true;
}
return (
getActiveRouteForQueryKey({
active,
queryKey: event.query.queryKey,
serverId,
}) !== null
);
}
function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]): boolean {
return (
type === "added" ||
type === "removed" ||
type === "observerAdded" ||
type === "observerRemoved" ||
type === "observerOptionsUpdated"
);
}
function getServerDataRoute(query: Query): ServerDataRoute | null {
const meta = query.meta;
if (!isRecord(meta) || !isRecord(meta.serverData)) {
return null;
}
return readServerDataRoute(meta.serverData);
}
function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute | null {
const domain = value.domain;
const enabled = value.enabled;
const serverId = value.serverId;
const cwd = value.cwd;
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
return null;
}
if (domain === "checkoutDiff") {
const subscriptionId = value.subscriptionId;
const compare = readCheckoutDiffCompare(value.compare);
if (typeof subscriptionId !== "string" || !compare) {
return null;
}
return {
domain,
enabled,
serverId,
subscriptionId,
cwd,
compare,
};
}
if (domain === "workspaceTerminals") {
const workspaceId = value.workspaceId;
if (workspaceId !== undefined && typeof workspaceId !== "string") {
return null;
}
return {
domain,
enabled,
serverId,
cwd,
...(workspaceId ? { workspaceId } : {}),
};
}
return null;
}
function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
if (!isRecord(value)) {
return null;
}
const mode = value.mode;
const baseRef = value.baseRef;
const ignoreWhitespace = value.ignoreWhitespace;
if (mode !== "uncommitted" && mode !== "base") {
return null;
}
if (baseRef !== undefined && typeof baseRef !== "string") {
return null;
}
if (ignoreWhitespace !== undefined && typeof ignoreWhitespace !== "boolean") {
return null;
}
return {
mode,
...(baseRef ? { baseRef } : {}),
...(ignoreWhitespace !== undefined ? { ignoreWhitespace } : {}),
};
}
function areCheckoutDiffRoutesEqual(
left: CheckoutDiffRoute | undefined,
right: CheckoutDiffRoute,
): boolean {
return (
left?.serverId === right.serverId &&
left.subscriptionId === right.subscriptionId &&
left.cwd === right.cwd &&
left.compare.mode === right.compare.mode &&
left.compare.baseRef === right.compare.baseRef &&
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
);
}
function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffRoute): boolean {
return (
queryKey[0] === "checkoutDiff" &&
queryKey[1] === route.serverId &&
queryKey[2] === route.cwd &&
queryKey[3] === route.compare.mode &&
queryKey[4] === (route.compare.baseRef ?? "") &&
queryKey[5] === (route.compare.ignoreWhitespace === true)
);
}
function areWorkspaceTerminalsRoutesEqual(
left: WorkspaceTerminalsRoute,
right: WorkspaceTerminalsRoute,
): boolean {
return (
left.serverId === right.serverId &&
left.cwd === right.cwd &&
left.workspaceId === right.workspaceId
);
}
function workspaceTerminalSubscriptionKey(route: WorkspaceTerminalsRoute): string {
return `${route.cwd}\u0000${route.workspaceId ?? ""}`;
}
function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): {
cwd: string;
workspaceId?: string;
} {
return {
cwd: route.cwd,
...(route.workspaceId ? { workspaceId: route.workspaceId } : {}),
};
}
function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void {
try {
client.unsubscribeCheckoutDiff(subscriptionId);
} catch {
// Disconnect cleanup can race with explicit subscription teardown.
}
}
function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean {
return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isDaemonConfigChangedPayload(
payload: StatusMessage["payload"],
): payload is { status: "daemon_config_changed"; config: MutableDaemonConfig } {
return payload.status === "daemon_config_changed" && isRecord(payload.config);
}

View File

@@ -0,0 +1,106 @@
import {
keepPreviousData,
skipToken,
useQuery,
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
type QueryFnOption<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = NonNullable<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>["queryFn"]
>;
type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
| "gcTime"
| "initialData"
| "refetchOnMount"
| "refetchOnReconnect"
| "refetchOnWindowFocus"
| "staleTime"
> & {
pushEvent: string;
};
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
> & {
dataShape: "list" | "value";
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
staleTimeMs: number;
};
export function useReplicaQuery<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
}
export function useFetchQuery<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input));
}
function replicaQueryOptions<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
const { pushEvent, meta, ...options } = input;
return {
...options,
gcTime: Infinity,
meta: {
...meta,
serverDataPolicy: {
class: "replica",
pushEvent,
},
},
queryFn: options.queryFn ?? skipToken,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
};
}
function fetchQueryOptions<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
if (!Number.isFinite(input.staleTimeMs)) {
throw new Error("Fetch queries must declare a finite staleTimeMs.");
}
const { dataShape, meta, staleTimeMs, ...options } = input;
return {
...options,
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
meta: {
...meta,
serverDataPolicy: {
class: "fetch",
dataShape,
},
},
refetchOnMount: "always",
staleTime: staleTimeMs,
};
}

View File

@@ -28,6 +28,11 @@ export interface DesktopDaemonLogs {
contents: string;
}
export interface DesktopAppLogs {
logPath: string;
contents: string;
}
export interface DesktopPairingOffer {
relayEnabled: boolean;
url: string | null;
@@ -144,6 +149,17 @@ export async function getDesktopDaemonLogs(): Promise<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
}
export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
const raw = await invokeDesktopCommand("desktop_app_logs");
if (!isRecord(raw)) {
throw new Error("Unexpected desktop app logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}

View File

@@ -34,6 +34,7 @@ interface UseBuiltInDaemonManagementInput {
interface UseBuiltInDaemonManagementResult {
isUpdating: boolean;
toggle: () => void;
enable: () => Promise<DaemonManagementToggleResult | null>;
}
export function useBuiltInDaemonManagement(
@@ -42,12 +43,16 @@ export function useBuiltInDaemonManagement(
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
DaemonManagementToggleResult,
Error
>({
mutationFn: async () => {
const wasManagingDaemon = settings.manageBuiltInDaemon;
const {
mutate: toggleDaemonManagement,
mutateAsync: toggleDaemonManagementAsync,
isPending: isUpdating,
} = useMutation<DaemonManagementToggleResult, Error, { forceEnable: boolean }>({
mutationFn: async ({ forceEnable }) => {
// forceEnable takes the enable branch regardless of the persisted
// setting — the recovery affordance must never reach the stop/confirm
// path even if manageBuiltInDaemon was left true.
const wasManagingDaemon = forceEnable ? false : settings.manageBuiltInDaemon;
try {
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
@@ -109,8 +114,21 @@ export function useBuiltInDaemonManagement(
return;
}
toggleDaemonManagement();
toggleDaemonManagement({ forceEnable: false });
}, [isUpdating, toggleDaemonManagement]);
return { isUpdating, toggle };
const enable = useCallback(async () => {
if (isUpdating) {
return null;
}
try {
return await toggleDaemonManagementAsync({ forceEnable: true });
} catch {
// onError has already surfaced the failure; callers only act on success.
return null;
}
}, [isUpdating, toggleDaemonManagementAsync]);
return { isUpdating, toggle, enable };
}

View File

@@ -0,0 +1,46 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { getIsElectron } from "@/constants/platform";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
export interface EnableBuiltInDaemonOption {
visible: boolean;
onPress: () => void;
}
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
const isElectron = getIsElectron();
const localDaemon = useLocalDaemonServerIdState();
const hosts = useHosts();
const { settings, updateSettings } = useDesktopSettings();
const { data, setStatus, refetch } = useDaemonStatus();
const { enable } = useBuiltInDaemonManagement({
daemonStatus: data?.status ?? null,
settings: settings.daemon,
updateSettings: (updates) => updateSettings({ daemon: updates }),
setStatus,
refreshStatus: refetch,
});
const isLocalhostConfigured =
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId);
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
const onPress = useCallback(() => {
void (async () => {
const result = await enable();
if (result?.kind === "enabled") {
router.push(buildSettingsHostSectionRoute(result.newStatus.serverId, "host"));
}
})();
}, [enable]);
return { visible, onPress };
}

View File

@@ -82,6 +82,7 @@ describe("app diagnostics report", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
[
"Desktop app log tail",
"secret.example.test:6767",
"relay.secret.test:443",
"daemon-public-key-secret",

View File

@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import {
collectDesktopDiagnosticSections,
type DesktopDiagnosticSources,
} from "./desktop-diagnostic-report";
function makeSources(): DesktopDiagnosticSources {
return {
getStatus: async () => ({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "host",
pid: 4242,
home: "/paseo/home",
version: "1.2.3",
desktopManaged: true,
error: null,
}),
getDaemonLogs: async () => ({
logPath: "/paseo/home/daemon.log",
contents: "daemon line one\ndaemon line two",
}),
getAppLogs: async () => ({
logPath: "/logs/Paseo/main.log",
contents: "[login-shell-env] start\n[login-shell-env] failed",
}),
};
}
describe("desktop diagnostic report", () => {
test("starts desktop diagnostic requests together", async () => {
const calls: string[] = [];
let releaseAppLogs: () => void = () => {};
const appLogGate = new Promise<void>((resolve) => {
releaseAppLogs = resolve;
});
const sources: DesktopDiagnosticSources = {
...makeSources(),
getStatus: async () => {
calls.push("status");
return makeSources().getStatus();
},
getDaemonLogs: async () => {
calls.push("daemonLogs");
return makeSources().getDaemonLogs();
},
getAppLogs: async () => {
calls.push("appLogs");
await appLogGate;
return makeSources().getAppLogs();
},
};
const resultPromise = collectDesktopDiagnosticSections(sources);
expect(calls).toEqual(["status", "daemonLogs", "appLogs"]);
releaseAppLogs();
await expect(resultPromise).resolves.toMatchObject({ status: "done" });
});
test("includes the Electron main-process log after the daemon log", async () => {
const result = await collectDesktopDiagnosticSections(makeSources());
const report = result.sections.join("\n\n");
expect(result.status).toBe("done");
expect(report).toContain(" Log path: /paseo/home/daemon.log");
expect(report).toContain(" App log path: /logs/Paseo/main.log");
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
expect(report).toContain(
"Desktop app log tail\n [login-shell-env] start\n [login-shell-env] failed",
);
expect(report.indexOf("Desktop app log tail")).toBeGreaterThan(
report.indexOf("Desktop daemon log tail"),
);
});
test("keeps daemon diagnostics when the Electron app log fails", async () => {
const sources = {
...makeSources(),
getAppLogs: async () => {
throw new Error("app log unavailable");
},
};
const result = await collectDesktopDiagnosticSections(sources);
const report = result.sections.join("\n\n");
expect(result.status).toBe("failed");
expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two");
expect(report).toContain("Desktop app log tail\n Error: app log unavailable");
});
});

View File

@@ -0,0 +1,106 @@
import {
getDesktopAppLogs,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
type DesktopAppLogs,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { formatDiagnosticSection } from "./app-diagnostic-report";
type DesktopDiagnosticStatus = "done" | "failed";
export interface DesktopDiagnosticCollectionResult {
sections: string[];
status: DesktopDiagnosticStatus;
}
export interface DesktopDiagnosticSources {
getStatus: () => Promise<DesktopDaemonStatus>;
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
getAppLogs: () => Promise<DesktopAppLogs>;
}
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
getStatus: getDesktopDaemonStatus,
getDaemonLogs: getDesktopDaemonLogs,
getAppLogs: getDesktopAppLogs,
};
export async function collectDesktopDiagnosticSections(
sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES,
): Promise<DesktopDiagnosticCollectionResult> {
const sections: string[] = [];
let failed = false;
const [daemonResult, appLogsResult] = await Promise.allSettled([
Promise.all([sources.getStatus(), sources.getDaemonLogs()]),
sources.getAppLogs(),
]);
if (daemonResult.status === "fulfilled") {
const [status, daemonLogs] = daemonResult.value;
const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null;
sections.unshift(...formatDesktopDaemonSections({ status, daemonLogs, appLogs }));
} else {
failed = true;
sections.unshift(
formatDiagnosticSection("Desktop", [
{ label: "Error", value: toMessage(daemonResult.reason) },
]),
);
}
if (appLogsResult.status === "fulfilled") {
sections.push(formatLogTailSection("Desktop app log tail", appLogsResult.value.contents));
} else {
failed = true;
sections.push(
formatDiagnosticSection("Desktop app log tail", [
{ label: "Error", value: toMessage(appLogsResult.reason) },
]),
);
}
return {
status: failed ? "failed" : "done",
sections,
};
}
function formatDesktopDaemonSections(input: {
status: DesktopDaemonStatus;
daemonLogs: DesktopDaemonLogs;
appLogs: DesktopAppLogs | null;
}): string[] {
const { status, daemonLogs, appLogs } = input;
return [
formatDiagnosticSection("Desktop", [
{ label: "Daemon status", value: status.status },
{ label: "Desktop managed", value: String(status.desktopManaged) },
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
{ label: "Daemon version", value: status.version ?? "unknown" },
{ label: "Daemon home", value: status.home || "unknown" },
{ label: "Log path", value: daemonLogs.logPath || "unknown" },
{ label: "App log path", value: appLogs?.logPath || "unavailable" },
{ label: "Error", value: status.error ?? "none" },
]),
formatLogTailSection("Desktop daemon log tail", daemonLogs.contents),
];
}
function formatLogTailSection(title: string, contents: string): string {
return [title, contents ? indentBlock(contents) : " No log lines found"].join("\n");
}
function indentBlock(value: string): string {
return value
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n");
}
function toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { queryClient as appQueryClient } from "@/query/query-client";
import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {

View File

@@ -1,7 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client";
import { queryClient as appQueryClient } from "@/data/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,

View File

@@ -1,8 +1,8 @@
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useMemo } from "react";
import { useReplicaQuery } from "@/data/query";
import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
@@ -44,10 +44,7 @@ export function useCheckoutDiffQuery({
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
@@ -59,111 +56,25 @@ export function useCheckoutDiffQuery({
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(enabled && isConnected && cwd);
const query = useQuery<CheckoutDiffQueryPayload>({
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
queryFn: skipToken,
staleTime: Infinity,
});
useEffect(() => {
if (!client || !isConnected || !cwd || !enabled) {
return;
}
const subscriptionId = [
"checkoutDiff",
hookInstanceId,
enabled: routeEnabled,
pushEvent: "checkout_diff_update",
meta: checkoutDiffPushRoute({
enabled: routeEnabled,
serverId,
subscriptionId,
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: `subscription:${subscriptionId}`,
});
});
const unsubscribeSubscribeResponse = client.on(
"subscribe_checkout_diff_response",
(message) => {
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: message.payload.requestId,
});
compare: {
mode: compareMode,
...(compareBaseRef ? { baseRef: compareBaseRef } : {}),
ignoreWhitespace: compareIgnoreWhitespace,
},
);
void client
.subscribeCheckoutDiff(
cwd,
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
.then((payload) => {
if (cancelled) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
});
return;
})
.catch((error) => {
if (cancelled) {
return;
}
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
serverId,
cwd,
error,
});
});
return () => {
cancelled = true;
unsubscribeUpdate();
unsubscribeSubscribeResponse();
try {
client.unsubscribeCheckoutDiff(subscriptionId);
} catch {
// Ignore disconnect race during effect cleanup.
}
};
}, [
client,
isConnected,
cwd,
enabled,
hookInstanceId,
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
}),
});
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;

View File

@@ -27,11 +27,13 @@ import {
combineInitialValues,
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
INITIAL_AGENT_FORM_RESOLUTION,
INITIAL_USER_MODIFIED,
RESOLVABLE_PROVIDER_STATUSES,
SELECTABLE_PROVIDER_STATUSES,
type FormInitialValues,
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
@@ -85,7 +87,6 @@ function shouldAutoSelectServerId(input: {
isVisible: boolean;
isCreateFlow: boolean;
isPreferencesLoading: boolean;
hasResolved: boolean;
userModifiedServerId: boolean;
initialServerId: string | null | undefined;
currentServerId: string | null;
@@ -94,20 +95,47 @@ function shouldAutoSelectServerId(input: {
isVisible,
isCreateFlow,
isPreferencesLoading,
hasResolved,
userModifiedServerId,
initialServerId,
currentServerId,
} = input;
if (!isVisible || !isCreateFlow) return false;
if (isPreferencesLoading) return false;
if (!hasResolved) return false;
if (userModifiedServerId) return false;
if (initialServerId !== undefined) return false;
if (currentServerId) return false;
return true;
}
function resolutionIntentKeyPart(value: string | null | undefined): string {
if (value === undefined) return "undefined";
if (value === null) return "null";
return value;
}
function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string {
if (!initialValues) return "none";
// workingDir seeds the open request, but locked cwd updates must not re-run
// provider/model/mode resolution after the form has settled.
return [
resolutionIntentKeyPart(initialValues.serverId),
resolutionIntentKeyPart(initialValues.provider),
resolutionIntentKeyPart(initialValues.modeId),
resolutionIntentKeyPart(initialValues.model),
resolutionIntentKeyPart(initialValues.thinkingOptionId),
].join("\n");
}
function hasSnapshotDataForResolution(input: {
serverId: string | null;
snapshotEntries: ProviderSnapshotEntry[] | undefined;
}): boolean {
if (!input.serverId) {
return false;
}
return input.snapshotEntries !== undefined;
}
function resolveSelectedProviderModes(input: {
selectedEntry: ProviderSnapshotEntry | null;
provider: AgentProvider | null;
@@ -133,6 +161,16 @@ function buildAllProviderModels(
return map;
}
function buildProviderModelsByProvider(
snapshotEntries: ProviderSnapshotEntry[] | undefined,
): ProviderModelsByProvider {
const map: ProviderModelsByProvider = new Map();
for (const entry of snapshotEntries ?? []) {
map.set(entry.provider, entry.models ?? null);
}
return map;
}
async function persistProviderPreferences(input: {
provider: AgentProvider;
formState: FormState;
@@ -175,7 +213,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
const [{ form: formState, userModified }, dispatch] = useReducer(
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(
resolveAgentForm,
initialServerId,
(serverId) => ({
@@ -188,6 +226,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
workingDir: "",
},
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
}),
);
@@ -196,14 +235,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
reducerStateRef.current = { form: formState, userModified };
}, [formState, userModified]);
const hasResolvedRef = useRef(false);
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
useEffect(() => {
if (!isVisible) {
dispatch({ type: "RESET" });
hasResolvedRef.current = false;
hydrationPreferencesRef.current = null;
}
}, [isVisible]);
@@ -245,6 +279,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
() => buildAllProviderModels(snapshotEntries),
[snapshotEntries],
);
const snapshotProviderModelsByProvider = useMemo(
() => buildProviderModelsByProvider(snapshotEntries),
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableProviderSelectorProviders(snapshotEntries),
[snapshotEntries],
@@ -276,37 +314,52 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
() => combineInitialValues(initialValues, initialServerId),
[initialValues, initialServerId],
);
const resolutionIntentKey = useMemo(
() => buildResolutionIntentKey(combinedInitialValues),
[combinedInitialValues],
);
useEffect(() => {
if (!isVisible || !isCreateFlow) {
return;
}
if (isPreferencesLoading && !hasResolvedRef.current) {
dispatch({ type: "REQUEST_RESOLUTION" });
}, [isVisible, isCreateFlow, resolutionIntentKey]);
useEffect(() => {
if (!isVisible || !isCreateFlow || resolution.status !== "pending") {
return;
}
if (isPreferencesLoading) {
return;
}
if (
!hasSnapshotDataForResolution({
serverId: formState.serverId,
snapshotEntries,
})
) {
return;
}
if (!hasResolvedRef.current) {
hydrationPreferencesRef.current = preferences;
}
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
dispatch({
type: "RESOLVE",
type: "COMPLETE_RESOLUTION",
initialValues: combinedInitialValues,
preferences: hydrationPreferences,
availableModels,
preferences,
providerModelsByProvider: snapshotProviderModelsByProvider,
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
});
hasResolvedRef.current = true;
}, [
isVisible,
combinedInitialValues,
formState.serverId,
isCreateFlow,
isPreferencesLoading,
combinedInitialValues,
isVisible,
preferences,
availableModels,
resolution.status,
snapshotEntries,
snapshotProviderModelsByProvider,
snapshotResolvableProviderDefinitionMap,
]);
@@ -316,10 +369,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isVisible,
isCreateFlow,
isPreferencesLoading,
hasResolved: hasResolvedRef.current,
userModifiedServerId: userModified.serverId,
initialServerId: combinedInitialValues?.serverId,
currentServerId: reducerStateRef.current.form.serverId,
currentServerId: formState.serverId,
});
if (!canAutoSelectServerId) return;
@@ -334,6 +386,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isVisible,
onlineServerIds,
onlineServerIdsKey,
formState.serverId,
userModified.serverId,
validServerIds,
]);
@@ -509,7 +562,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
? providerDefinitionMap.get(formState.provider)
: undefined;
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptionsRaw = effectiveModel?.thinkingOptions;
const availableThinkingOptions = useMemo(
() => availableThinkingOptionsRaw ?? [],
@@ -529,7 +581,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setProviderFromUser,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: resolvedModelId,
selectedModel: formState.model,
setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser,
@@ -560,7 +612,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
formState.serverId,
formState.provider,
formState.modeId,
resolvedModelId,
formState.model,
formState.thinkingOptionId,
formState.workingDir,
setSelectedServerId,

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
import { useReplicaQuery } from "@/data/query";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
export function daemonConfigQueryKey(serverId: string | null) {
return ["daemon-config", serverId] as const;
}
interface UseDaemonConfigResult {
config: MutableDaemonConfig | null;
isLoading: boolean;
@@ -21,10 +19,10 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
const configQuery = useQuery({
const configQuery = useReplicaQuery({
queryKey,
enabled: Boolean(serverId && client && isConnected),
staleTime: Infinity,
pushEvent: "status:daemon_config_changed",
queryFn: async () => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
@@ -34,22 +32,6 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
},
});
useEffect(() => {
if (!client || !isConnected || !serverId) {
return;
}
return client.on("status", (message) => {
if (message.type !== "status") {
return;
}
if (message.payload.status !== "daemon_config_changed") {
return;
}
queryClient.setQueryData(queryKey, message.payload.config as MutableDaemonConfig);
});
}, [client, isConnected, queryClient, queryKey, serverId]);
const patchConfig = useCallback(
async (patch: MutableDaemonConfigPatch) => {
if (!client) {

View File

@@ -15,10 +15,10 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
};
}
export function useLocalDaemonServerId(): string | null {
function useLocalDaemonServerIdQuery() {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useQuery({
return useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktopApp,
@@ -30,6 +30,11 @@ export function useLocalDaemonServerId(): string | null {
refetchOnWindowFocus: false,
retry: false,
});
}
export function useLocalDaemonServerId(): string | null {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useLocalDaemonServerIdQuery();
if (!isDesktopApp) {
return null;
@@ -38,6 +43,27 @@ export function useLocalDaemonServerId(): string | null {
return query.data?.serverId ?? null;
}
export type LocalDaemonServerIdState =
| { status: "loading" }
| { status: "error" }
| { status: "resolved"; serverId: string | null };
export function useLocalDaemonServerIdState(): LocalDaemonServerIdState {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useLocalDaemonServerIdQuery();
if (!isDesktopApp) {
return { status: "resolved", serverId: null };
}
if (query.isError) {
return { status: "error" };
}
if (query.isSuccess) {
return { status: "resolved", serverId: query.data.serverId };
}
return { status: "loading" };
}
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim();
const localServerId = useLocalDaemonServerId();

View File

@@ -1,7 +1,5 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { projectsQueryKey } from "@/hooks/use-projects";
import { useSessionStore } from "@/stores/session-store";
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
@@ -11,7 +9,6 @@ export function useOpenProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const queryClient = useQueryClient();
const canAddProject = useSessionStore((state) =>
normalizedServerId
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
@@ -31,13 +28,6 @@ export function useOpenProject(
addEmptyProject,
setHasHydratedWorkspaces,
});
// The aggregated projects query derives the project list from a fetch
// that now includes empty projects; refetch so a freshly-added project
// (no workspace yet) is immediately editable instead of only after a
// restart.
if (result.ok) {
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
}
return result;
},
[
@@ -46,7 +36,6 @@ export function useOpenProject(
client,
isConnected,
normalizedServerId,
queryClient,
setHasHydratedWorkspaces,
],
);

View File

@@ -1,92 +1,48 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import {
fetchAggregatedProjects,
type ProjectsHostInput,
type ProjectsRuntime,
type ProjectsRuntimeSnapshot,
} from "@/projects/aggregated-projects";
deriveProjectsFromReplica,
type ProjectHostReplica,
type ProjectHostRuntimeState,
} from "@/hooks/use-projects";
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
interface HostFixture {
function runtimeState(input: {
serverId: string;
serverName: string;
status: "online" | "offline" | "missing-snapshot";
workspaces?: WorkspaceDescriptorPayload[] | Error;
}
interface RuntimeAdapter extends ProjectsRuntime {
fetchCalls: Map<string, number>;
}
function createRuntime(hosts: HostFixture[]): RuntimeAdapter {
const snapshots = new Map<string, ProjectsRuntimeSnapshot | null>();
const clients = new Map<string, Pick<DaemonClient, "fetchWorkspaces"> | null>();
const fetchCalls = new Map<string, number>();
for (const host of hosts) {
if (host.status === "missing-snapshot") {
snapshots.set(host.serverId, null);
} else {
snapshots.set(host.serverId, { connectionStatus: host.status });
}
if (host.workspaces === undefined) {
clients.set(host.serverId, null);
continue;
}
const workspaces = host.workspaces;
const fetchWorkspaces: FetchWorkspaces = async () => {
fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1);
if (workspaces instanceof Error) {
throw workspaces;
}
return {
requestId: `req-${host.serverId}`,
entries: workspaces,
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
} satisfies FetchWorkspacesResult;
};
clients.set(host.serverId, { fetchWorkspaces });
}
isOnline: boolean;
isLoading?: boolean;
isFetching?: boolean;
error?: string | null;
}): ProjectHostRuntimeState {
return {
getClient: (serverId) => clients.get(serverId) ?? null,
getSnapshot: (serverId) => snapshots.get(serverId),
fetchCalls,
serverId: input.serverId,
isOnline: input.isOnline,
isLoading: input.isLoading ?? false,
isFetching: input.isFetching ?? false,
error: input.error ?? null,
};
}
function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] {
return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName }));
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptorPayload {
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
title: null,
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
@@ -115,13 +71,26 @@ function workspace(input: {
};
}
describe("fetchAggregatedProjects", () => {
it("calls every online host's client and aggregates projects sorted by display name", async () => {
const hosts: HostFixture[] = [
function emptyProject(input: {
projectKey: string;
projectName: string;
cwd: string;
}): EmptyProjectDescriptor {
return {
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
projectKind: "git",
};
}
describe("deriveProjectsFromReplica", () => {
it("aggregates store workspaces and empty projects sorted by display name", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
status: "online",
workspaces: [
workspace({
id: "z-main",
@@ -131,41 +100,50 @@ describe("fetchAggregatedProjects", () => {
remoteUrl: "https://github.com/acme/zeta.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
status: "online",
workspaces: [
workspace({
id: "a-main",
workspaces: [],
emptyProjects: [
emptyProject({
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
remoteUrl: "https://github.com/acme/alpha.git",
}),
],
},
];
const runtime = createRuntime(hosts);
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true }),
runtimeState({ serverId: "laptop", isOnline: false }),
],
});
expect(runtime.fetchCalls.get("local")).toBe(1);
expect(runtime.fetchCalls.get("laptop")).toBe(1);
expect(result.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
expect(result.projects[0]?.hosts).toEqual([
expect.objectContaining({
serverId: "laptop",
isOnline: false,
repoRoot: "/repo/alpha",
workspaceCount: 0,
}),
]);
expect(result.hostErrors).toEqual([]);
});
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
const hosts: HostFixture[] = [
it("maps runtime directory status into loading and host errors", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
status: "online",
workspaces: [
workspace({
id: "main",
@@ -175,17 +153,28 @@ describe("fetchAggregatedProjects", () => {
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
status: "online",
workspaces: new Error("laptop unavailable"),
workspaces: [],
emptyProjects: [],
},
];
const runtime = createRuntime(hosts);
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
runtimeState({
serverId: "laptop",
isOnline: true,
isLoading: true,
error: "laptop unavailable",
}),
],
});
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
@@ -197,61 +186,30 @@ describe("fetchAggregatedProjects", () => {
message: "laptop unavailable",
},
]);
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
});
it("skips disconnected hosts silently without surfacing them as failures", async () => {
const hosts: HostFixture[] = [
{
serverId: "local",
serverName: "Local",
status: "online",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
status: "offline",
},
];
const runtime = createRuntime(hosts);
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
expect(result.hostErrors).toEqual([]);
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(runtime.fetchCalls.get("laptop")).toBeUndefined();
});
it("returns only the stable public project and host entry shapes", async () => {
const hosts: HostFixture[] = [
{
serverId: "local",
serverName: "Local",
status: "online",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
},
];
const runtime = createRuntime(hosts);
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
it("returns only the stable public project and host entry shapes", () => {
const result = deriveProjectsFromReplica({
replicas: [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
],
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
});
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
"githubUrl",

View File

@@ -1,23 +1,45 @@
import { useMemo, useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import type { ProjectSummary } from "@/utils/projects";
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
fetchAggregatedProjects,
type ProjectHostError,
type ProjectsHostInput,
} from "@/projects/aggregated-projects";
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHosts,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import {
useSessionStore,
type EmptyProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export type {
ProjectHostError,
ProjectsHostInput,
ProjectsRuntime,
} from "@/projects/aggregated-projects";
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
}
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
return hosts.map((host) => host.serverId).join("|");
export interface ProjectHostRuntimeState {
serverId: string;
isOnline: boolean;
isLoading: boolean;
isFetching: boolean;
error: string | null;
}
export interface DerivedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
}
export interface UseProjectsResult {
@@ -28,36 +50,112 @@ export interface UseProjectsResult {
refetch: () => void;
}
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
function toProjectHostRuntimeState(
serverId: string,
snapshot: HostRuntimeSnapshot | null,
): ProjectHostRuntimeState {
const isFetching =
snapshot?.agentDirectoryStatus === "initial_loading" ||
snapshot?.agentDirectoryStatus === "revalidating";
return {
serverId,
isOnline: snapshot?.connectionStatus === "online",
isLoading: isHostRuntimeDirectoryLoading(snapshot),
isFetching,
error: snapshot?.agentDirectoryError ?? null,
};
}
function selectProjectHostReplicas(
hosts: readonly { serverId: string; label: string }[],
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
return (state) =>
hosts.map((host) => {
const session = state.sessions[host.serverId];
return {
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
};
});
}
export function deriveProjectsFromReplica(input: {
replicas: readonly ProjectHostReplica[];
runtimeStates: readonly ProjectHostRuntimeState[];
}): DerivedProjectsResult {
const runtimeByServerId = new Map(
input.runtimeStates.map((state) => [state.serverId, state] as const),
);
const hosts: ProjectHost[] = input.replicas.map((replica) => {
const runtimeState = runtimeByServerId.get(replica.serverId);
return {
serverId: replica.serverId,
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
const message = runtimeByServerId.get(replica.serverId)?.error;
return message
? [
{
serverId: replica.serverId,
serverName: replica.serverName,
message,
},
]
: [];
});
return {
...buildProjects({ hosts }),
hostErrors,
isLoading: input.runtimeStates.some((state) => state.isLoading),
isFetching: input.runtimeStates.some((state) => state.isFetching),
};
}
function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostRuntimeState[] {
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
const runtimeSnapshotTick = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const hostInputs = useMemo<ProjectsHostInput[]>(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
[hosts],
);
return useMemo(() => {
void runtimeSnapshotTick;
const nextStates = serverIds.map((serverId) =>
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
);
if (equal(previousStatesRef.current, nextStates)) {
return previousStatesRef.current;
}
previousStatesRef.current = nextStates;
return nextStates;
}, [runtime, runtimeSnapshotTick, serverIds]);
}
const projectsQuery = useQuery({
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
staleTime: 5_000,
});
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal);
const runtimeStates = useProjectHostRuntimeStates(serverIds);
const derived = useMemo(
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
[replicas, runtimeStates],
);
const refetch = useCallback(() => {
runtime.refreshAllAgentDirectories({ serverIds });
}, [runtime, serverIds]);
return {
projects: projectsQuery.data?.projects ?? [],
hostErrors: projectsQuery.data?.hostErrors ?? [],
isLoading: projectsQuery.isLoading,
isFetching: projectsQuery.isFetching,
refetch: () => {
void projectsQuery.refetch();
},
...derived,
refetch,
};
}

View File

@@ -4,14 +4,13 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
import {
applyProvidersSnapshotUpdate,
fetchProvidersSnapshot,
providersSnapshotQueryKey,
refreshAndApplyProvidersSnapshot,
selectorOpenRefetchDecision,
type ProvidersSnapshotClient,
type ProvidersSnapshotUpdateMessage,
} from "./use-providers-snapshot";
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
@@ -223,10 +222,7 @@ describe("applyProvidersSnapshotUpdate", () => {
queryClient = new QueryClient();
});
function updateMessage(
entries: ProviderSnapshotEntry[],
cwd?: string,
): ProvidersSnapshotUpdateMessage {
function updateMessage(entries: ProviderSnapshotEntry[], cwd?: string): ProvidersSnapshotUpdate {
return {
type: "providers_snapshot_update",
payload: {

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client";
import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import {
isProvidersSnapshotHomeScope,
@@ -13,7 +14,7 @@ import {
providersSnapshotQueryKey,
providersSnapshotQueryRoot,
providersSnapshotRequestOptions,
} from "@/hooks/providers-snapshot-query";
} from "@/data/providers-snapshot";
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
@@ -25,15 +26,6 @@ export type ProvidersSnapshotClient = Pick<
"getProvidersSnapshot" | "refreshProvidersSnapshot"
>;
export interface ProvidersSnapshotUpdateMessage {
type: "providers_snapshot_update";
payload: {
cwd?: string;
entries: ProviderSnapshotEntry[];
generatedAt: string;
};
}
export async function fetchProvidersSnapshot(input: {
client: ProvidersSnapshotClient;
cwd: string | null;
@@ -66,26 +58,6 @@ export async function refreshAndApplyProvidersSnapshot(input: {
return refreshResult;
}
export function applyProvidersSnapshotUpdate(input: {
serverId: string;
queryClient: QueryClient;
message: ProvidersSnapshotUpdateMessage;
}): void {
if (input.message.type !== "providers_snapshot_update") {
return;
}
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
input.queryClient.setQueryData(queryKey, {
entries: input.message.payload.entries,
generatedAt: input.message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
exact: false,
});
}
export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";
export function selectorOpenRefetchDecision(input: {
@@ -134,10 +106,10 @@ export function useProvidersSnapshot(
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
const snapshotQuery = useQuery({
const snapshotQuery = useReplicaQuery({
queryKey,
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000,
pushEvent: "providers_snapshot_update",
queryFn: async () => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
@@ -162,19 +134,6 @@ export function useProvidersSnapshot(
});
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
useEffect(() => {
if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
applyProvidersSnapshotUpdate({ serverId, queryClient, message });
});
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
const refresh = useCallback(
async (providers?: AgentProvider[]) => {
await refreshSnapshot(providers);
@@ -218,7 +177,7 @@ export function prefetchProvidersSnapshot(
const queryKey = providersSnapshotQueryKey(serverId, cwd);
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
staleTime: Infinity,
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
});
}

View File

@@ -0,0 +1,91 @@
import { QueryClient } from "@tanstack/react-query";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
FetchAggregatedSchedulesState,
} from "@/schedules/aggregated-schedules";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { updateAggregatedSchedulesData } from "./use-schedule-mutations";
function schedule(overrides: Partial<AggregatedSchedule> = {}): AggregatedSchedule {
const base: ScheduleSummary = {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
};
return { ...base, serverId: "host-a", serverName: "Host A", ...overrides };
}
function pauseSchedules(schedules: AggregatedSchedule[]): AggregatedSchedule[] {
return schedules.map((entry) => ({ ...entry, status: "paused" }));
}
function pauseAggregatedSchedules(
current: FetchAggregatedSchedulesState | undefined,
): FetchAggregatedSchedulesState | undefined {
return updateAggregatedSchedulesData(current, pauseSchedules);
}
describe("schedule mutation cache updates", () => {
it("updates the canonical loaded data field", () => {
const current: FetchAggregatedSchedulesResult = {
status: "loaded",
data: [schedule()],
hostErrors: [],
};
const result = updateAggregatedSchedulesData(current, pauseSchedules);
expect(result).toEqual({
status: "loaded",
data: [schedule({ status: "paused" })],
hostErrors: [],
});
});
it("leaves an empty cache entry empty", () => {
const result = updateAggregatedSchedulesData(undefined, () => [schedule()]);
expect(result).toBeUndefined();
});
it("leaves connecting cache entries untouched while updating loaded entries", () => {
const queryClient = new QueryClient();
const connectingKey = [...schedulesQueryBaseKey, "connecting"];
const loadedKey = [...schedulesQueryBaseKey, "loaded"];
const connecting = { status: "connecting" } as const;
const loaded: FetchAggregatedSchedulesResult = {
status: "loaded",
data: [schedule()],
hostErrors: [],
};
queryClient.setQueryData(connectingKey, connecting);
queryClient.setQueryData(loadedKey, loaded);
expect(() => {
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
{ queryKey: schedulesQueryBaseKey },
pauseAggregatedSchedules,
);
}).not.toThrow();
expect(queryClient.getQueryData(connectingKey)).toEqual(connecting);
expect(queryClient.getQueryData(loadedKey)).toEqual({
status: "loaded",
data: [schedule({ status: "paused" })],
hostErrors: [],
});
});
});

View File

@@ -12,11 +12,11 @@ import type {
UpdateScheduleOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
FetchAggregatedSchedulesState,
} from "@/schedules/aggregated-schedules";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -38,7 +38,17 @@ export interface UseScheduleMutationsResult {
}
interface ScheduleListSnapshot {
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
previous: Array<[QueryKey, FetchAggregatedSchedulesState | undefined]>;
}
export function updateAggregatedSchedulesData(
current: FetchAggregatedSchedulesState | undefined,
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
): FetchAggregatedSchedulesState | undefined {
if (!current || current.status !== "loaded") {
return current;
}
return { ...current, data: updateSchedules(current.data) };
}
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
@@ -51,7 +61,7 @@ function requireClient(serverId: string, unavailableMessage: string): DaemonClie
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
return {
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
previous: queryClient.getQueriesData<FetchAggregatedSchedulesState>({
queryKey: schedulesQueryBaseKey,
}),
};
@@ -67,14 +77,9 @@ function updateSchedulesData(
queryClient: QueryClient,
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
{ queryKey: schedulesQueryBaseKey },
(current) => {
if (!current) {
return current;
}
return { ...current, schedules: updateSchedules(current.schedules) };
},
(current) => updateAggregatedSchedulesData(current, updateSchedules),
);
}

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { schedulesQueryKey } from "@/hooks/use-schedules";
describe("schedulesQueryKey", () => {
it("uses only the sorted host identity", () => {
expect(schedulesQueryKey(["laptop", "local"])).toEqual(["schedules", "laptop|local"]);
expect(schedulesQueryKey(["local", "laptop"])).toEqual(["schedules", "laptop|local"]);
});
});

View File

@@ -1,30 +1,32 @@
import { useMemo, useSyncExternalStore } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useMemo } from "react";
import { useFetchQuery } from "@/data/query";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
schedulesQueryBaseKey,
type AggregateLoadState,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
} from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export type {
AggregateLoadState,
AggregatedSchedule,
ScheduleHostError,
} from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
// Cache identity for the host set. The query also carries the runtime version
// (below) so it retries as connectivity changes and reliably fetches once a host
// comes online — even on a cold deep-link. The full-screen spinner flash that
// keying on the version used to cause is prevented by keepPreviousData plus the
// isInitialLoad(data === undefined) gate, not by dropping the version.
export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
}
export interface UseSchedulesResult {
schedules: AggregatedSchedule[];
loadState: AggregateLoadState<AggregatedSchedule>;
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
@@ -34,27 +36,36 @@ export interface UseSchedulesResult {
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 serverIds = useMemo(() => hostInputs.map((host) => host.serverId), [hostInputs]);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
const connectionStatusKey = useMemo(
() => serverIds.map((serverId) => connectionStatuses.get(serverId) ?? "connecting").join("|"),
[connectionStatuses, serverIds],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
const query = useFetchQuery({
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
dataShape: "list",
staleTimeMs: 5_000,
});
let loadState: AggregateLoadState<AggregatedSchedule>;
if (query.data?.status === "connecting") {
loadState = { status: "connecting" };
} else if (query.data?.status === "loaded") {
loadState = { status: "loaded", data: query.data.data };
} else {
loadState = { status: "loading" };
}
return {
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
loadState,
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
isError: query.isError,
error: query.error,
refetch: () => {

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/query/query-client";
import { queryClient as appQueryClient } from "@/data/query-client";
import type { AppLanguage } from "@/i18n/locales";
import {
DEFAULT_DESKTOP_SETTINGS,

View File

@@ -1404,6 +1404,7 @@ export const ar: TranslationResources = {
},
backToWorkspace: "خلف",
addHost: "أضف مضيفًا",
enableBuiltInDaemon: "تفعيل البرنامج الخفي المدمج",
projects: "المشاريع",
projectList: {
hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}",
@@ -1412,7 +1413,6 @@ export const ar: TranslationResources = {
groupInfo: "حول{{title}}",
sections: {
general: "عام",
daemon: "Daemon",
appearance: "مظهر",
shortcuts: "الاختصارات",
integrations: "التكامل",
@@ -1427,7 +1427,7 @@ export const ar: TranslationResources = {
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "Host",
host: "نظرة عامة",
},
general: {
title: "عام",

View File

@@ -1412,6 +1412,7 @@ export const en = {
},
backToWorkspace: "Back",
addHost: "Add host",
enableBuiltInDaemon: "Enable built-in daemon",
projects: "Projects",
projectList: {
hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}",
@@ -1420,7 +1421,6 @@ export const en = {
groupInfo: "About {{title}}",
sections: {
general: "General",
daemon: "Daemon",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
@@ -1435,7 +1435,7 @@ export const en = {
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Host",
host: "Overview",
},
general: {
title: "General",

View File

@@ -1443,6 +1443,7 @@ export const es: TranslationResources = {
},
backToWorkspace: "Atrás",
addHost: "Agregar anfitrión",
enableBuiltInDaemon: "Activar el demonio integrado",
projects: "Proyectos",
projectList: {
hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}",
@@ -1451,7 +1452,6 @@ export const es: TranslationResources = {
groupInfo: "Acerca de{{title}}",
sections: {
general: "General",
daemon: "Daemon",
appearance: "Apariencia",
shortcuts: "Atajos",
integrations: "Integraciones",
@@ -1466,7 +1466,7 @@ export const es: TranslationResources = {
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Host",
host: "Resumen",
},
general: {
title: "General",

View File

@@ -1446,6 +1446,7 @@ export const fr: TranslationResources = {
},
backToWorkspace: "Dos",
addHost: "Ajouter un hôte",
enableBuiltInDaemon: "Activer le démon intégré",
projects: "Projets",
projectList: {
hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}",
@@ -1454,7 +1455,6 @@ export const fr: TranslationResources = {
groupInfo: "À propos de{{title}}",
sections: {
general: "Général",
daemon: "Daemon",
appearance: "Apparence",
shortcuts: "Raccourcis",
integrations: "Intégrations",
@@ -1469,7 +1469,7 @@ export const fr: TranslationResources = {
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Host",
host: "Aperçu",
},
general: {
title: "Général",

View File

@@ -1421,6 +1421,7 @@ export const ja: TranslationResources = {
},
backToWorkspace: "戻る",
addHost: "ホストを追加",
enableBuiltInDaemon: "組み込みデーモンを有効にする",
projects: "プロジェクト",
projectList: {
hostLoadFailed: "ホスト{{hostName}}からプロジェクトを読み込めませんでした: {{message}}",
@@ -1429,7 +1430,6 @@ export const ja: TranslationResources = {
groupInfo: "{{title}}について",
sections: {
general: "一般",
daemon: "デーモン",
appearance: "外観",
shortcuts: "ショートカット",
integrations: "連携",
@@ -1444,7 +1444,7 @@ export const ja: TranslationResources = {
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
host: "ホスト",
host: "概要",
},
general: {
title: "一般",

View File

@@ -1429,6 +1429,7 @@ export const ptBR: TranslationResources = {
},
backToWorkspace: "Voltar",
addHost: "Adicionar host",
enableBuiltInDaemon: "Ativar o daemon integrado",
projects: "Projetos",
projectList: {
hostLoadFailed: "Não foi possível carregar projetos do host {{hostName}}: {{message}}",
@@ -1437,7 +1438,6 @@ export const ptBR: TranslationResources = {
groupInfo: "Sobre {{title}}",
sections: {
general: "Geral",
daemon: "Daemon",
appearance: "Aparência",
shortcuts: "Atalhos",
integrations: "Integrações",
@@ -1452,7 +1452,7 @@ export const ptBR: TranslationResources = {
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
host: "Host",
host: "Visão geral",
},
general: {
title: "Geral",

View File

@@ -1435,6 +1435,7 @@ export const ru: TranslationResources = {
},
backToWorkspace: "Назад",
addHost: "Добавить хост",
enableBuiltInDaemon: "Включить встроенный демон",
projects: "Проекты",
projectList: {
hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.",
@@ -1443,7 +1444,6 @@ export const ru: TranslationResources = {
groupInfo: "О{{title}}",
sections: {
general: "Общий",
daemon: "Daemon",
appearance: "Появление",
shortcuts: "Ярлыки",
integrations: "Интеграции",
@@ -1458,7 +1458,7 @@ export const ru: TranslationResources = {
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Host",
host: "Обзор",
},
general: {
title: "Общий",

View File

@@ -1387,6 +1387,7 @@ export const zhCN: TranslationResources = {
},
backToWorkspace: "返回",
addHost: "添加主机",
enableBuiltInDaemon: "启用内置 daemon",
projects: "项目",
projectList: {
hostLoadFailed: "无法从 Host {{hostName}} 加载 projects{{message}}",
@@ -1395,7 +1396,6 @@ export const zhCN: TranslationResources = {
groupInfo: "关于 {{title}}",
sections: {
general: "通用",
daemon: "Daemon",
appearance: "外观",
shortcuts: "快捷键",
integrations: "集成",
@@ -1410,7 +1410,7 @@ export const zhCN: TranslationResources = {
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
host: "Host",
host: "概览",
},
general: {
title: "通用",

View File

@@ -9,7 +9,7 @@ import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-
import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { queryClient } from "@/query/query-client";
import { queryClient } from "@/data/query-client";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";

View File

@@ -1,105 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
emptyProjects: [],
},
error: null,
};
}
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "name", direction: "asc" }],
});
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces,
emptyProjects,
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
emptyProjects: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}

View File

@@ -260,6 +260,25 @@ describe("combined model selector data", () => {
).toBe("Default");
});
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
}),
]);
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "codex",
selectedModel: "gpt-5.3",
isLoading: false,
}),
).toBe("gpt-5.3");
});
it("keeps provider snapshot errors visible in the selected trigger label", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({

View File

@@ -177,6 +177,10 @@ export function resolveSelectedModelLabel(input: {
}
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
const selectedModel = input.selectedModel.trim();
if (!model && selectedModel) {
return selectedModel;
}
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
return (
model?.modelLabel ??

View File

@@ -9,7 +9,10 @@ import {
buildProviderDefinitionMapForStatuses,
resolveDefaultModel,
INITIAL_USER_MODIFIED,
PENDING_AGENT_FORM_RESOLUTION,
type AgentFormReducerState,
type AgentFormResolutionState,
type ProviderModelsByProvider,
type UserModifiedFields,
} from "./resolve-agent-form";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
@@ -71,6 +74,7 @@ const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFIN
function makeState(
overrides: Partial<AgentFormReducerState["form"]> = {},
modified: Partial<UserModifiedFields> = {},
resolution: AgentFormResolutionState = PENDING_AGENT_FORM_RESOLUTION,
): AgentFormReducerState {
return {
form: {
@@ -83,9 +87,16 @@ function makeState(
...overrides,
},
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
resolution,
};
}
function makeProviderModelsByProvider(
entries: Array<[AgentProvider, AgentModelDefinition[] | null]>,
): ProviderModelsByProvider {
return new Map(entries);
}
describe("resolveDefaultModel", () => {
it("returns null for empty or null input", () => {
expect(resolveDefaultModel(null)).toBeNull();
@@ -705,44 +716,138 @@ describe("resolveFormState", () => {
});
describe("resolveAgentForm", () => {
describe("RESOLVE", () => {
it("applies resolved provider and mode when no user modifications", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "RESOLVE",
initialValues: undefined,
preferences: { provider: "codex" },
availableModels: null,
describe("resolution state", () => {
it("requests resolution without changing the current form values", () => {
const state = makeState(
{ provider: "codex", modeId: "auto", model: "gpt-5.3-codex" },
{ provider: true, model: true },
{ status: "completed" },
);
const next = resolveAgentForm(state, { type: "REQUEST_RESOLUTION" });
expect(next.form).toEqual(state.form);
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
expect(next.resolution.status).toBe("pending");
});
it("completes a pending open resolution when snapshot models arrive late", () => {
const state = resolveAgentForm(makeState({ serverId: "host-1" }), {
type: "REQUEST_RESOLUTION",
});
const next = resolveAgentForm(state, {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
},
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
allowedProviderMap: codexProviderMap,
});
expect(next.form.provider).toBe("codex");
expect(next.form.modeId).toBe("auto");
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.thinkingOptionId).toBe("xhigh");
expect(next.resolution.status).toBe("completed");
});
it("returns the same state reference when nothing changed", () => {
const state = makeState({ provider: "codex", modeId: "auto" });
const next = resolveAgentForm(state, {
type: "RESOLVE",
it("does not change settled selection when a background snapshot has different defaults", () => {
const settled = resolveAgentForm(makeState({ serverId: "host-1" }), {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: { provider: "codex" },
availableModels: null,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
},
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
allowedProviderMap: codexProviderMap,
});
const backgroundModels: AgentModelDefinition[] = [
{ provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex", isDefault: true },
];
const next = resolveAgentForm(settled, {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.4-codex" } },
},
providerModelsByProvider: makeProviderModelsByProvider([["codex", backgroundModels]]),
allowedProviderMap: codexProviderMap,
});
expect(next).toBe(state);
expect(next).toBe(settled);
expect(next.form.provider).toBe("codex");
expect(next.form.model).toBe("gpt-5.3-codex");
});
it("does not override user-modified provider", () => {
it("prefills edit hydration from initial values", () => {
const state = makeState({ serverId: "host-1" });
const next = resolveAgentForm(state, {
type: "COMPLETE_RESOLUTION",
initialValues: {
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "low",
workingDir: "/repo",
},
preferences: { provider: "claude" },
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
allowedProviderMap: bothProviderMap,
});
expect(next.form.provider).toBe("codex");
expect(next.form.modeId).toBe("full-access");
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.thinkingOptionId).toBe("low");
expect(next.form.workingDir).toBe("/repo");
});
it("keeps a user model change after resolution has completed", () => {
const alternateModels: AgentModelDefinition[] = [
...CODEX_MODELS,
{ provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex" },
];
const settled = resolveAgentForm(makeState({ serverId: "host-1" }), {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
},
providerModelsByProvider: makeProviderModelsByProvider([["codex", alternateModels]]),
allowedProviderMap: codexProviderMap,
});
const userChanged = resolveAgentForm(settled, {
type: "SET_MODEL_FROM_USER",
modelId: "gpt-5.4-codex",
availableModels: alternateModels,
});
const next = resolveAgentForm(userChanged, {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "gpt-5.3-codex" } },
},
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
allowedProviderMap: codexProviderMap,
});
expect(next).toBe(userChanged);
expect(next.form.model).toBe("gpt-5.4-codex");
expect(next.userModified.model).toBe(true);
});
it("does not override user-modified provider while completing", () => {
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
const next = resolveAgentForm(state, {
type: "RESOLVE",
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: { provider: "claude" },
availableModels: null,
providerModelsByProvider: makeProviderModelsByProvider([]),
allowedProviderMap: bothProviderMap,
});
@@ -976,11 +1081,13 @@ describe("resolveAgentForm", () => {
const state = makeState(
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
{ provider: true, modeId: true, model: true },
{ status: "completed" },
);
const next = resolveAgentForm(state, { type: "RESET" });
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
expect(next.form).toEqual(state.form);
expect(next.resolution.status).toBe("completed");
});
});

View File

@@ -37,9 +37,14 @@ export interface UserModifiedFields {
workingDir: boolean;
}
export type ProviderModelsByProvider = Map<AgentProvider, AgentModelDefinition[] | null>;
export type AgentFormResolutionState = { status: "pending" } | { status: "completed" };
export interface AgentFormReducerState {
form: FormState;
userModified: UserModifiedFields;
resolution: AgentFormResolutionState;
}
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
@@ -51,6 +56,9 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = {
workingDir: false,
};
export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" };
export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" };
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
@@ -60,11 +68,12 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["statu
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
export type AgentFormAction =
| { type: "REQUEST_RESOLUTION" }
| {
type: "RESOLVE";
type: "COMPLETE_RESOLUTION";
initialValues: FormInitialValues | undefined;
preferences: FormPreferences | null;
availableModels: AgentModelDefinition[] | null;
providerModelsByProvider: ProviderModelsByProvider;
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
}
| { type: "SET_SERVER_ID"; value: string | null }
@@ -97,6 +106,8 @@ export type AgentFormAction =
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
| { type: "RESET" };
type CompleteResolutionAction = Extract<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
return typeof modelId === "string" ? modelId.trim() : "";
}
@@ -404,6 +415,36 @@ export function resolveFormState(
return result;
}
export function resolveFormStateFromProviderModels(
initialValues: FormInitialValues | undefined,
preferences: FormPreferences | null,
providerModelsByProvider: ProviderModelsByProvider,
userModified: UserModifiedFields,
currentState: FormState,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState {
const providerResolved = resolveFormState(
initialValues,
preferences,
null,
userModified,
currentState,
allowedProviderMap,
);
const availableModels = providerResolved.provider
? (providerModelsByProvider.get(providerResolved.provider) ?? null)
: null;
return resolveFormState(
initialValues,
preferences,
availableModels,
userModified,
currentState,
allowedProviderMap,
);
}
function pickNextModelForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
@@ -460,29 +501,47 @@ function pickNextThinkingOptionForProvider(input: {
});
}
function completeResolution(
state: AgentFormReducerState,
action: CompleteResolutionAction,
): AgentFormReducerState {
if (state.resolution.status === "completed") {
return state;
}
const resolved = resolveFormStateFromProviderModels(
action.initialValues,
action.preferences,
action.providerModelsByProvider,
state.userModified,
state.form,
action.allowedProviderMap,
);
const nextState = { ...state, resolution: { status: "completed" } as const };
if (!hasFormStateChanged(state.form, resolved)) return nextState;
return { ...nextState, form: resolved };
}
export function resolveAgentForm(
state: AgentFormReducerState,
action: AgentFormAction,
): AgentFormReducerState {
switch (action.type) {
case "RESOLVE": {
const resolved = resolveFormState(
action.initialValues,
action.preferences,
action.availableModels,
state.userModified,
state.form,
action.allowedProviderMap,
);
if (!hasFormStateChanged(state.form, resolved)) return state;
return { ...state, form: resolved };
}
case "REQUEST_RESOLUTION":
return {
...state,
userModified: INITIAL_USER_MODIFIED,
resolution: PENDING_AGENT_FORM_RESOLUTION,
};
case "COMPLETE_RESOLUTION":
return completeResolution(state, action);
case "SET_SERVER_ID":
return { ...state, form: { ...state.form, serverId: action.value } };
case "SET_SERVER_ID_FROM_USER":
return {
...state,
form: { ...state.form, serverId: action.value },
userModified: { ...state.userModified, serverId: true },
};
@@ -502,6 +561,7 @@ export function resolveAgentForm(
modelId: nextModelId,
});
return {
...state,
form: {
...state.form,
provider: action.provider,
@@ -529,6 +589,7 @@ export function resolveAgentForm(
providerPrefs: action.providerPrefs,
});
return {
...state,
form: {
...state.form,
provider: action.provider,
@@ -542,6 +603,7 @@ export function resolveAgentForm(
case "SET_MODE_FROM_USER":
return {
...state,
form: { ...state.form, modeId: action.modeId },
userModified: { ...state.userModified, modeId: true },
};
@@ -557,6 +619,7 @@ export function resolveAgentForm(
: "",
});
return {
...state,
form: {
...state.form,
model: nextModelId,
@@ -568,6 +631,7 @@ export function resolveAgentForm(
case "CLEAR_PROVIDER_SELECTION_FROM_USER":
return {
...state,
form: {
...state.form,
provider: null,
@@ -586,6 +650,7 @@ export function resolveAgentForm(
case "SET_THINKING_OPTION_FROM_USER":
return {
...state,
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
userModified: { ...state.userModified, thinkingOptionId: true },
};
@@ -595,6 +660,7 @@ export function resolveAgentForm(
case "SET_WORKING_DIR_FROM_USER":
return {
...state,
form: { ...state.form, workingDir: action.value },
userModified: { ...state.userModified, workingDir: true },
};
@@ -604,7 +670,11 @@ export function resolveAgentForm(
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
case "RESET":
return { ...state, userModified: INITIAL_USER_MODIFIED };
return {
...state,
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
};
default:
throw new Error("unreachable");
}

View File

@@ -1775,6 +1775,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("preserves a manual host rename when desktop status re-advertises the daemon hostname", async () => {
const advertisedHostname = "macbook-pro.local";
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: advertisedHostname,
}),
getClientId: async () => "cid_test_runtime",
},
storage: createMemoryHostRuntimeStorage(),
});
try {
await store.upsertConnectionFromListen({
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: advertisedHostname,
});
await store.renameHost("srv_desktop", "mac-dev");
await store.upsertConnectionFromListen({
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: advertisedHostname,
});
expect(store.getHosts().find((h) => h.serverId === "srv_desktop")?.label).toBe("mac-dev");
} finally {
store.syncHosts([]);
}
});
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
@@ -1982,7 +2017,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -1993,6 +2028,7 @@ describe("HostRuntimeStore", () => {
}),
getClientId: async () => "cid_test_runtime",
},
storage: createMemoryHostRuntimeStorage(),
});
await store.upsertRelayConnection({
@@ -2005,7 +2041,7 @@ describe("HostRuntimeStore", () => {
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
expect(pairedHost?.label).toBe("Custom name");
store.syncHosts([]);
});

View File

@@ -47,8 +47,13 @@ import {
shouldUseLegacyDaemonWorkspaceDirectory,
} from "@/workspace/legacy-daemon-workspaces";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/query/query-client";
import { queryClient } from "@/data/query-client";
import {
invalidateServerDataQueriesAfterReconnect,
mountServerDataPushRouter,
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -585,10 +590,21 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
const unmountServerData = mountServerDataPushRouter({
client,
queryClient,
serverId: host.serverId,
});
if (!browserAutomationCapabilities) {
return () => {};
return unmountServerData;
}
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
serverId: host.serverId,
});
return () => {
unmountBrowserAutomation();
unmountServerData();
};
},
};
}
@@ -1974,6 +1990,8 @@ export class HostRuntimeStore {
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
// queries refetch now and evicted ones on their next mount.
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}
// Runtime owns directory bootstrap policy, including reconnect and delayed

View File

@@ -0,0 +1,130 @@
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { describe, expect, it } from "vitest";
import {
fetchAggregatedSchedules,
type ScheduleRuntime,
type ScheduleRuntimeSnapshot,
} from "./aggregated-schedules";
function makeSchedule(overrides: Partial<ScheduleSummary> = {}): ScheduleSummary {
return {
id: "schedule-1",
name: "Nightly",
prompt: "Run the task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T01:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
...overrides,
};
}
function makeRuntime(input: {
snapshots: Record<string, ScheduleRuntimeSnapshot | null>;
schedules?: Record<string, ScheduleSummary[]>;
}): ScheduleRuntime {
return {
getSnapshot: (serverId) => input.snapshots[serverId] ?? null,
getClient: (serverId) => {
const schedules = input.schedules?.[serverId];
if (!schedules) {
return null;
}
return {
scheduleList: async () => ({ requestId: "test-request", schedules, error: null }),
};
},
};
}
describe("fetchAggregatedSchedules load state", () => {
it("does not report loaded empty while known hosts are still connecting", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "connecting" },
"host-b": { connectionStatus: "connecting" },
},
}),
});
expect(result.status).not.toBe("loaded");
expect(result).toEqual({ status: "connecting" });
});
it("reports loaded empty after all reachable hosts answer with no schedules", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "online" },
},
schedules: {
"host-a": [],
"host-b": [],
},
}),
});
expect(result).toEqual({ status: "loaded", data: [], hostErrors: [] });
});
it("does not report loaded empty while another known host is still connecting", async () => {
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "connecting" },
},
schedules: {
"host-a": [],
},
}),
});
expect(result).toEqual({ status: "connecting" });
});
it("loads reachable host data when another known host is still connecting", async () => {
const schedule = makeSchedule();
const result = await fetchAggregatedSchedules({
hosts: [
{ serverId: "host-a", serverName: "Host A" },
{ serverId: "host-b", serverName: "Host B" },
],
runtime: makeRuntime({
snapshots: {
"host-a": { connectionStatus: "online" },
"host-b": { connectionStatus: "connecting" },
},
schedules: {
"host-a": [schedule],
},
}),
});
expect(result).toEqual({
status: "loaded",
data: [{ ...schedule, serverId: "host-a", serverName: "Host A" }],
hostErrors: [],
});
});
});

View File

@@ -2,6 +2,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { toErrorMessage } from "@/utils/error-messages";
export const schedulesQueryBaseKey = ["schedules"] as const;
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
@@ -31,11 +33,25 @@ export interface ScheduleHostError {
message: string;
}
export interface FetchAggregatedSchedulesConnectingResult {
status: "connecting";
}
export interface FetchAggregatedSchedulesResult {
schedules: AggregatedSchedule[];
status: "loaded";
data: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
}
export type FetchAggregatedSchedulesState =
| FetchAggregatedSchedulesConnectingResult
| FetchAggregatedSchedulesResult;
export type AggregateLoadState<T> =
| { status: "connecting" }
| { status: "loading" }
| { status: "loaded"; data: T[] };
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
runtime: ScheduleRuntime;
@@ -43,9 +59,8 @@ export interface FetchAggregatedSchedulesInput {
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
* Connectivity is checked here at execution time, so explicit query refreshes
* pick up the currently connected host set.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
@@ -53,7 +68,19 @@ export interface FetchAggregatedSchedulesInput {
*/
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
): Promise<FetchAggregatedSchedulesState> {
const hasSettlingHost = input.hosts.some((host) =>
isScheduleHostConnectionSettling(input.runtime.getSnapshot(host.serverId)),
);
const hasAskableHost = input.hosts.some((host) => {
const snapshot = input.runtime.getSnapshot(host.serverId);
return snapshot?.connectionStatus === "online" && input.runtime.getClient(host.serverId);
});
if (!hasAskableHost && hasSettlingHost) {
return { status: "connecting" };
}
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
@@ -89,5 +116,18 @@ export async function fetchAggregatedSchedules(
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
if (schedules.length === 0 && hasSettlingHost) {
return { status: "connecting" };
}
return { status: "loaded", data: schedules, hostErrors };
}
function isScheduleHostConnectionSettling(
snapshot: ScheduleRuntimeSnapshot | null | undefined,
): boolean {
if (!snapshot) {
return true;
}
return snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "idle";
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetId,
} from "./schedule-cadence-options";
describe("schedule cadence form options", () => {
it("offers the approved cron preset vocabulary", () => {
expect(CADENCE_PRESET_OPTIONS.map((option) => option.label)).toEqual([
"Every minute",
"Every hour",
"Daily 9:00",
"Weekdays 9:00",
"Mondays 9:00",
]);
});
it("maps interval cadences to cron cadences for the form", () => {
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "* * * * *",
timezone: "Europe/Madrid",
});
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 60 * 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "0 * * * *",
timezone: "Europe/Madrid",
});
expect(
normalizeScheduleFormCadence({ type: "every", everyMs: 24 * 60 * 60_000 }, "Europe/Madrid"),
).toEqual({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
});
it("maps unsupported intervals to the closest custom cron expression", () => {
const cadence = normalizeScheduleFormCadence(
{ type: "every", everyMs: 5 * 60_000 },
"Europe/Madrid",
);
expect(cadence).toEqual({
type: "cron",
expression: "*/5 * * * *",
timezone: "Europe/Madrid",
});
expect(resolveCronPresetId(cadence)).toBe("custom");
});
});

View File

@@ -0,0 +1,62 @@
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import { everyMsToParts } from "@/utils/schedule-format";
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadencePresetOption {
id: string;
label: string;
expression: string;
}
export const CUSTOM_CRON_PRESET_ID = "custom";
export const CADENCE_PRESET_OPTIONS: CadencePresetOption[] = [
{ id: "every-minute", label: "Every minute", expression: "* * * * *" },
{ id: "every-hour", label: "Every hour", expression: "0 * * * *" },
{ id: "daily-9", label: "Daily 9:00", expression: "0 9 * * *" },
{ id: "weekdays-9", label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ id: "mondays-9", label: "Mondays 9:00", expression: "0 9 * * 1" },
];
export function resolveCronPresetId(cadence: CronCadence): string {
const expression = cadence.expression.trim();
return (
CADENCE_PRESET_OPTIONS.find((option) => option.expression === expression)?.id ??
CUSTOM_CRON_PRESET_ID
);
}
export function resolveCronPresetDisplay(cadence: CronCadence): { label: string } {
return {
label:
CADENCE_PRESET_OPTIONS.find((option) => option.id === resolveCronPresetId(cadence))?.label ??
"Custom cron",
};
}
export function normalizeScheduleFormCadence(
cadence: ScheduleCadence,
timezone: string,
): CronCadence {
if (cadence.type === "cron") {
return { ...cadence, timezone: cadence.timezone ?? timezone };
}
return {
type: "cron",
expression: everyMsToCronExpression(cadence.everyMs),
timezone,
};
}
function everyMsToCronExpression(everyMs: number): string {
const { value, unit } = everyMsToParts(everyMs);
if (unit === "minutes") {
return value === 1 ? "* * * * *" : `*/${Math.min(value, 59)} * * * *`;
}
if (unit === "hours") {
return value === 1 ? "0 * * * *" : `0 */${Math.min(value, 23)} * * *`;
}
return value === 1 ? "0 9 * * *" : `0 9 */${Math.min(value, 31)} * *`;
}

View File

@@ -0,0 +1,612 @@
import type {
AgentMode,
AgentModelDefinition,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { describe, expect, it } from "vitest";
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
type TestSchedule = ScheduleSummary & { serverId: string; serverName: string };
const HOSTS = [
{ serverId: "host-a", label: "Host A", supportsWorkspaceMultiplicity: true },
{ serverId: "host-b", label: "Host B", supportsWorkspaceMultiplicity: true },
] as const;
const MOCK_MODES: AgentMode[] = [{ id: "load-test", label: "Load test" }];
const HOST_A_MODELS: AgentModelDefinition[] = [
{
provider: "mock",
id: "model-a",
label: "Model A",
isDefault: true,
},
];
const HOST_B_MODELS: AgentModelDefinition[] = [
{
provider: "mock",
id: "model-b",
label: "Model B",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High", isDefault: true },
],
},
];
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
function target(input: {
serverId: string;
projectKey: string;
projectName: string;
cwd: string;
isGit?: boolean;
}): ScheduleProjectTarget {
return {
optionId: buildProjectOptionId(input.serverId, input.projectKey),
serverId: input.serverId,
serverName: input.serverId === "host-a" ? "Host A" : "Host B",
projectKey: input.projectKey,
projectName: input.projectName,
cwd: input.cwd,
isGit: input.isGit ?? true,
};
}
const PROJECT_TARGETS = [
target({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
cwd: "/repo/a",
}),
target({
serverId: "host-b",
projectKey: "project-b",
projectName: "Project B",
cwd: "/repo/b",
}),
];
function scheduleOnHost(input: {
serverId: string;
serverName: string;
cwd: string;
model: string;
cadence?: ScheduleSummary["cadence"];
}): TestSchedule {
return {
id: `schedule-${input.serverId}`,
serverId: input.serverId,
serverName: input.serverName,
name: "Nightly",
prompt: "Run the schedule",
cadence: input.cadence ?? { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: input.cwd,
model: input.model,
modeId: "load-test",
thinkingOptionId: "high",
archiveOnFinish: false,
isolation: "worktree",
},
},
status: "active",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
nextRunAt: "2026-07-02T00:00:00.000Z",
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: 3,
};
}
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
return {
entries: [
{
provider: "mock",
label: "Mock",
status: "ready",
enabled: true,
fetchedAt: "2026-07-01T00:00:00.000Z",
models,
modes: MOCK_MODES,
defaultModeId: "load-test",
},
],
};
}
function open(snapshot: Omit<ScheduleFormSnapshot, "hosts">) {
return openScheduleForm({
...snapshot,
hosts: HOSTS,
});
}
function openWithHosts(snapshot: ScheduleFormSnapshot) {
return openScheduleForm(snapshot);
}
function applyPreferences(form: ReturnType<typeof open>, preferences: FormPreferences) {
form.applyPreferences(preferences);
}
describe("schedule form model", () => {
it("opens edit from the schedule host snapshot and completes that host resolution", () => {
const previous = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
previous.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
previous.close();
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(form.getState()).toMatchObject({
mode: "edit",
selectedServerId: "host-b",
workingDir: "/repo/b",
selectedProvider: "mock",
selectedModel: "model-b",
selectedMode: "load-test",
selectedThinkingOptionId: "high",
projectDisplay: { label: "Project B" },
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
archiveOnFinish: false,
isolation: "worktree",
effectiveIsolation: "worktree",
providerResolutionByServerId: { "host-b": "pending" },
providerSnapshotRequest: {
serverId: "host-b",
cwd: "/repo/b",
},
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
selectedModelDisplay: { label: "Model B" },
selectedModeDisplay: { label: "Load test" },
selectedThinkingDisplay: { label: "High" },
providerResolutionByServerId: { "host-b": "complete" },
providerSnapshotRequest: null,
});
});
it("opens create pristine after an edit instance closes", () => {
const edit = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
edit.close();
const create = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(create.getState()).toMatchObject({
mode: "create",
selectedServerId: "host-a",
workingDir: "",
selectedProvider: null,
selectedModel: "",
selectedMode: "",
selectedThinkingOptionId: "",
projectDisplay: null,
selectedProjectOptionId: "",
selectedModelDisplay: null,
selectedThinkingDisplay: null,
archiveOnFinish: true,
isolation: "local",
effectiveIsolation: "local",
providerResolutionByServerId: {},
});
});
it("derives the host to project to model disclosure chain from model state", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: false,
showThinkingField: false,
showModeField: false,
showIsolationField: false,
showArchiveOnFinishField: false,
});
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: true,
showThinkingField: false,
showModeField: false,
showIsolationField: true,
showArchiveOnFinishField: true,
});
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_B_MODELS));
form.setModel("mock", "model-b");
expect(form.getState().disclosure).toEqual({
showProjectField: true,
showModelField: true,
showThinkingField: true,
showModeField: true,
showIsolationField: true,
showArchiveOnFinishField: true,
});
});
it("hides isolation unless the selected project can create a worktree", () => {
const nonGitTarget = target({
serverId: "host-a",
projectKey: "plain-project",
projectName: "Plain Project",
cwd: "/tmp/plain",
isGit: false,
});
const nonGit = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: [nonGitTarget], preferences: {} },
});
nonGit.setProject(nonGitTarget.optionId, { label: "Plain Project" });
expect(nonGit.getState().disclosure).toMatchObject({
showIsolationField: false,
showArchiveOnFinishField: true,
});
const gitTarget = target({
serverId: "host-a",
projectKey: "git-project",
projectName: "Git Project",
cwd: "/tmp/git",
isGit: true,
});
const unsupportedHost = openWithHosts({
mode: "create",
hosts: [{ serverId: "host-a", label: "Host A" }],
defaults: { serverId: "host-a", projectTargets: [gitTarget], preferences: {} },
});
unsupportedHost.setProject(gitTarget.optionId, { label: "Git Project" });
expect(unsupportedHost.getState().disclosure).toMatchObject({
showIsolationField: false,
showArchiveOnFinishField: false,
});
expect(
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitIsolation"),
).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitArchiveOnFinish"),
).toBe(true);
expect(
(unsupportedHost.getState() as unknown as { submitIsolation?: string }).submitIsolation,
).toBeUndefined();
expect(
(unsupportedHost.getState() as unknown as { submitArchiveOnFinish?: boolean })
.submitArchiveOnFinish,
).toBeUndefined();
});
it("shows modes for providers without selectable models", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
form.applyProviderSnapshot("host-a", providerSnapshot([]));
form.setModel("mock", "");
expect(form.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "",
disclosure: {
showModeField: true,
showThinkingField: false,
},
});
});
it("preserves stored worktree isolation until host resolution proves it unavailable", () => {
const nonGitTarget = target({
serverId: "host-b",
projectKey: "plain-project",
projectName: "Plain Project",
cwd: "/repo/b",
isGit: false,
});
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: [nonGitTarget], preferences: {} },
});
expect(form.getState()).toMatchObject({
isolation: "worktree",
effectiveIsolation: "worktree",
canUseWorktreeIsolation: false,
providerResolutionByServerId: { "host-b": "pending" },
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
isolation: "worktree",
effectiveIsolation: "local",
canUseWorktreeIsolation: false,
providerResolutionByServerId: { "host-b": "complete" },
});
});
it("normalizes interval cadences to cron cadences when opening the form", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
cadence: { type: "every", everyMs: 60_000 },
}),
defaults: {
serverId: null,
projectTargets: PROJECT_TARGETS,
preferences: {},
timezone: "Europe/Madrid",
},
});
expect(form.getState().cadence).toEqual({
type: "cron",
expression: "* * * * *",
timezone: "Europe/Madrid",
});
});
it("preserves an edited schedule's interval cadence until the cadence changes", () => {
const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
cadence: originalCadence,
}),
defaults: {
serverId: null,
projectTargets: PROJECT_TARGETS,
preferences: {},
timezone: "Europe/Madrid",
},
});
expect(form.getState().cadence).toEqual({
type: "cron",
expression: "*/59 * * * *",
timezone: "Europe/Madrid",
});
form.setName("Renamed without touching cadence");
expect(form.getState().submitCadence).toEqual(originalCadence);
form.setCadence({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
expect(form.getState().submitCadence).toEqual({
type: "cron",
expression: "0 9 * * *",
timezone: "Europe/Madrid",
});
});
it("clears provider selection while resolving a different project", () => {
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
form.setPrompt("Run on a selected project.");
form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" });
form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS));
form.setModel("mock", "model-a");
expect(form.getState()).toMatchObject({
selectedServerId: "host-a",
workingDir: "/repo/a",
selectedProvider: "mock",
selectedModel: "model-a",
canSubmit: true,
});
form.setProject(buildProjectOptionId("host-b", "project-b"), { label: "Project B" });
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
workingDir: "/repo/b",
selectedProvider: null,
selectedModel: "",
modelSelectorProviders: [],
canSubmit: false,
providerResolutionByServerId: { "host-a": "complete", "host-b": "pending" },
providerSnapshotRequest: { serverId: "host-b", cwd: "/repo/b" },
});
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
expect(form.getState()).toMatchObject({
selectedServerId: "host-b",
selectedProvider: null,
selectedModel: "",
providerResolutionByServerId: { "host-a": "complete", "host-b": "complete" },
providerSnapshotRequest: null,
modelSelectorProviders: [expect.objectContaining({ id: "mock", label: "Mock" })],
});
});
it("applies new project targets without resetting selected values", () => {
const projectA = PROJECT_TARGETS[0];
const form = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: [projectA], preferences: {} },
});
form.setName("Draft name");
form.setPrompt("Draft prompt");
form.setProject(projectA.optionId, { label: "Project A" });
form.applyProjectTargets([
projectA,
target({
serverId: "host-a",
projectKey: "project-c",
projectName: "Project C",
cwd: "/repo/c",
}),
]);
expect(form.getState()).toMatchObject({
name: "Draft name",
prompt: "Draft prompt",
selectedServerId: "host-a",
workingDir: "/repo/a",
projectDisplay: { label: "Project A" },
selectedProjectOptionId: projectA.optionId,
projectOptions: [
{
id: projectA.optionId,
value: projectA.optionId,
label: "Project A",
testID: "schedule-project-option-project-a",
},
{
id: buildProjectOptionId("host-a", "project-c"),
value: buildProjectOptionId("host-a", "project-c"),
label: "Project C",
testID: "schedule-project-option-project-c",
},
],
});
});
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
const savedPreferences: FormPreferences = {
provider: "mock",
providerPreferences: {
mock: {
model: "model-b",
mode: "load-test",
thinkingByModel: { "model-b": "high" },
},
},
isolation: "worktree",
};
const create = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
create.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
applyPreferences(create, savedPreferences);
expect(create.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-b",
selectedMode: "load-test",
selectedThinkingOptionId: "high",
isolation: "worktree",
});
const userEdited = open({
mode: "create",
defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} },
});
userEdited.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
userEdited.setModel("mock", "model-a");
userEdited.setIsolation("local");
applyPreferences(userEdited, savedPreferences);
expect(userEdited.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-a",
isolation: "local",
});
const edit = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-a",
serverName: "Host A",
cwd: "/repo/a",
model: "model-a",
}),
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
});
edit.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS));
applyPreferences(edit, { ...savedPreferences, isolation: "local" });
expect(edit.getState()).toMatchObject({
selectedProvider: "mock",
selectedModel: "model-a",
selectedMode: "load-test",
isolation: "worktree",
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ export interface ScheduleProjectTarget {
projectKey: string;
projectName: string;
cwd: string;
isGit: boolean;
}
export function buildProjectOptionId(serverId: string, projectKey: string): string {
@@ -38,6 +39,7 @@ export function buildScheduleProjectTargets(
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
isGit: Boolean(host.gitRuntime),
});
}
}

View File

@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets";
import { useScheduleFormModel } from "./use-schedule-form-model";
import type { ScheduleFormSnapshot } from "./schedule-form-model";
const HOSTS = [{ serverId: "host-a", label: "Host A" }] as const;
const PROJECT_A_ID = buildProjectOptionId("host-a", "project-a");
function projectTarget(input: {
projectKey: string;
projectName: string;
cwd: string;
}): ScheduleProjectTarget {
return {
optionId: buildProjectOptionId("host-a", input.projectKey),
serverId: "host-a",
serverName: "Host A",
projectKey: input.projectKey,
projectName: input.projectName,
cwd: input.cwd,
isGit: true,
};
}
function createSnapshot(projectTargets: readonly ScheduleProjectTarget[]): ScheduleFormSnapshot {
return {
mode: "create",
hosts: HOSTS,
defaults: {
serverId: "host-a",
projectTargets,
preferences: {},
},
};
}
describe("useScheduleFormModel", () => {
afterEach(() => {
cleanup();
});
it("keeps one model instance and draft state while open snapshot inputs churn", () => {
const firstTargets = [
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
];
const { result, rerender } = renderHook(({ snapshot }) => useScheduleFormModel(snapshot), {
initialProps: { snapshot: createSnapshot(firstTargets) },
});
const openedModel = result.current;
act(() => {
openedModel.setName("Draft name");
openedModel.setPrompt("Run the draft");
openedModel.setProject(PROJECT_A_ID, { label: "Project A" });
});
rerender({
snapshot: createSnapshot([
projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }),
]),
});
expect(result.current).toBe(openedModel);
expect(result.current.getState()).toMatchObject({
name: "Draft name",
prompt: "Run the draft",
selectedServerId: "host-a",
workingDir: "/repo/a",
projectDisplay: { label: "Project A" },
selectedProjectOptionId: PROJECT_A_ID,
});
});
});

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model";
export function useScheduleFormModel(snapshot: ScheduleFormSnapshot) {
const [model] = useState(() => openScheduleForm(snapshot));
useEffect(() => {
return () => {
model.close();
};
}, [model]);
useEffect(() => {
model.applyHosts(snapshot.hosts);
model.applyProjectTargets(snapshot.defaults.projectTargets);
model.applyPreferences(snapshot.defaults.preferences);
}, [model, snapshot.hosts, snapshot.defaults.preferences, snapshot.defaults.projectTargets]);
return model;
}

View File

@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { ScheduleFormModel, ScheduleFormState } from "./schedule-form-model";
export function useScheduleFormProviderSnapshot(
model: ScheduleFormModel,
state: ScheduleFormState,
) {
const serverId = state.providerSnapshotRequest?.serverId ?? state.selectedServerId;
const cwd = state.providerSnapshotRequest?.cwd ?? state.workingDir;
const enabled = state.targetKind === "new-agent" && Boolean(serverId && cwd.trim());
const snapshot = useProvidersSnapshot(serverId ?? null, {
cwd,
enabled,
});
useEffect(() => {
if (!enabled || !serverId || !snapshot.entries) {
return;
}
model.applyProviderSnapshot(serverId, { entries: snapshot.entries });
}, [enabled, model, serverId, snapshot.entries]);
return snapshot;
}

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