From d9b65e269f7c875713ecb55a31f0eb74e2441469 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Jul 2026 22:13:42 +0200 Subject: [PATCH] Scheduled runs each get their own workspace in the sidebar (#1934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .oxlintrc.json | 192 ++ CLAUDE.md | 1 + docs/floating-panels.md | 23 + docs/forms.md | 98 + packages/app/e2e/fixtures.ts | 16 +- packages/app/e2e/helpers/no-truncation.ts | 61 + .../app/e2e/helpers/schedule-fake-host.ts | 51 +- packages/app/e2e/helpers/settled.ts | 86 + ...w-workspace-codex-mode-preferences.spec.ts | 19 + .../new-workspace-isolation-memory.spec.ts | 8 +- .../schedules-edit-model-hydration.spec.ts | 162 +- .../app/e2e/schedules-project-target.spec.ts | 310 +++- packages/app/src/app/_layout.tsx | 2 +- .../src/components/adaptive-modal-sheet.tsx | 72 +- .../components/combined-model-selector.tsx | 189 +- .../app/src/components/hosts/host-picker.tsx | 4 +- packages/app/src/components/left-sidebar.tsx | 1 + .../components/schedules/cadence-editor.tsx | 477 +---- .../schedules/schedule-form-sheet.tsx | 1634 +++++++++-------- packages/app/src/components/ui/button.tsx | 272 +-- .../ui/combobox-frame-style.test.ts | 58 + .../src/components/ui/combobox-frame-style.ts | 42 + .../src/components/ui/combobox-trigger.tsx | 42 +- packages/app/src/components/ui/combobox.tsx | 52 +- .../components/ui/control-geometry.test.ts | 113 ++ .../app/src/components/ui/control-geometry.ts | 224 +++ packages/app/src/components/ui/form-field.tsx | 253 ++- .../src/components/ui/segmented-control.tsx | 171 +- .../app/src/components/ui/select-field.tsx | 394 ++++ .../app/src/components/ui/switch.test.tsx | 16 +- packages/app/src/components/ui/switch.tsx | 151 +- .../app/src/composer/agent-controls/index.tsx | 6 + packages/app/src/data/daemon-config.ts | 3 + .../providers-snapshot.test.ts} | 2 +- .../providers-snapshot.ts} | 0 packages/app/src/data/push-router.test.ts | 518 ++++++ packages/app/src/data/push-router.ts | 754 ++++++++ .../app/src/{query => data}/query-client.ts | 0 packages/app/src/data/query.ts | 106 ++ packages/app/src/git/actions-store.test.ts | 2 +- packages/app/src/git/actions-store.ts | 2 +- packages/app/src/git/use-diff-query.ts | 125 +- .../app/src/hooks/use-agent-form-state.ts | 108 +- packages/app/src/hooks/use-daemon-config.ts | 30 +- packages/app/src/hooks/use-open-project.ts | 11 - packages/app/src/hooks/use-projects.test.ts | 230 +-- packages/app/src/hooks/use-projects.ts | 176 +- .../src/hooks/use-providers-snapshot.test.ts | 8 +- .../app/src/hooks/use-providers-snapshot.ts | 57 +- .../src/hooks/use-schedule-mutations.test.ts | 91 + .../app/src/hooks/use-schedule-mutations.ts | 27 +- packages/app/src/hooks/use-schedules.test.ts | 9 + packages/app/src/hooks/use-schedules.ts | 61 +- packages/app/src/hooks/use-settings/index.ts | 2 +- packages/app/src/panels/terminal-panel.tsx | 2 +- .../app/src/projects/aggregated-projects.ts | 105 -- .../provider-selection.test.ts | 19 + .../provider-selection/provider-selection.ts | 4 + .../resolve-agent-form.test.ts | 147 +- .../provider-selection/resolve-agent-form.ts | 100 +- packages/app/src/runtime/host-runtime.ts | 24 +- .../schedules/aggregated-schedules.test.ts | 130 ++ .../app/src/schedules/aggregated-schedules.ts | 52 +- .../schedule-cadence-options.test.ts | 56 + .../src/schedules/schedule-cadence-options.ts | 62 + .../src/schedules/schedule-form-model.test.ts | 612 ++++++ .../app/src/schedules/schedule-form-model.ts | 1054 +++++++++++ .../src/schedules/schedule-project-targets.ts | 2 + .../use-schedule-form-model.test.tsx | 75 + .../src/schedules/use-schedule-form-model.ts | 20 + .../use-schedule-form-provider-snapshot.ts | 25 + .../screens/schedules-screen-state.test.ts | 13 + .../app/src/screens/schedules-screen-state.ts | 23 + packages/app/src/screens/schedules-screen.tsx | 128 +- .../terminal-profile-edit-modal.test.tsx | 8 +- .../settings/terminal-profile-edit-modal.tsx | 14 +- .../terminals/use-workspace-terminals.ts | 73 +- .../app/src/utils/schedule-format.test.ts | 1 + packages/app/src/utils/schedule-format.ts | 18 +- packages/client/src/daemon-client.test.ts | 125 ++ packages/client/src/daemon-client.ts | 5 + .../protocol/src/schedule/rpc-schemas.test.ts | 54 +- packages/protocol/src/schedule/rpc-schemas.ts | 7 +- packages/protocol/src/schedule/types.ts | 7 +- packages/server/src/server/bootstrap.ts | 67 +- .../server/schedule-run-lifecycle.e2e.test.ts | 362 ++++ .../src/server/schedule/service.test.ts | 1428 ++++++-------- .../server/src/server/schedule/service.ts | 468 ++--- packages/server/src/server/schedule/store.ts | 3 +- 89 files changed, 9316 insertions(+), 3499 deletions(-) create mode 100644 docs/forms.md create mode 100644 packages/app/e2e/helpers/no-truncation.ts create mode 100644 packages/app/e2e/helpers/settled.ts create mode 100644 packages/app/src/components/ui/combobox-frame-style.test.ts create mode 100644 packages/app/src/components/ui/combobox-frame-style.ts create mode 100644 packages/app/src/components/ui/control-geometry.test.ts create mode 100644 packages/app/src/components/ui/control-geometry.ts create mode 100644 packages/app/src/components/ui/select-field.tsx create mode 100644 packages/app/src/data/daemon-config.ts rename packages/app/src/{hooks/providers-snapshot-query.test.ts => data/providers-snapshot.test.ts} (98%) rename packages/app/src/{hooks/providers-snapshot-query.ts => data/providers-snapshot.ts} (100%) create mode 100644 packages/app/src/data/push-router.test.ts create mode 100644 packages/app/src/data/push-router.ts rename packages/app/src/{query => data}/query-client.ts (100%) create mode 100644 packages/app/src/data/query.ts create mode 100644 packages/app/src/hooks/use-schedule-mutations.test.ts create mode 100644 packages/app/src/hooks/use-schedules.test.ts delete mode 100644 packages/app/src/projects/aggregated-projects.ts create mode 100644 packages/app/src/schedules/aggregated-schedules.test.ts create mode 100644 packages/app/src/schedules/schedule-cadence-options.test.ts create mode 100644 packages/app/src/schedules/schedule-cadence-options.ts create mode 100644 packages/app/src/schedules/schedule-form-model.test.ts create mode 100644 packages/app/src/schedules/schedule-form-model.ts create mode 100644 packages/app/src/schedules/use-schedule-form-model.test.tsx create mode 100644 packages/app/src/schedules/use-schedule-form-model.ts create mode 100644 packages/app/src/schedules/use-schedule-form-provider-snapshot.ts create mode 100644 packages/app/src/screens/schedules-screen-state.test.ts create mode 100644 packages/app/src/screens/schedules-screen-state.ts create mode 100644 packages/server/src/server/schedule-run-lifecycle.e2e.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 73ad555ce..9e389e515 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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": { diff --git a/CLAUDE.md b/CLAUDE.md index eb53d32eb..61f6358fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 48f506f49..bb2ae59f5 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -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 diff --git a/docs/forms.md b/docs/forms.md new file mode 100644 index 000000000..9f809ed0c --- /dev/null +++ b/docs/forms.md @@ -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 = + | { 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). diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 0cf61ba21..ceda33e0a 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -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(); diff --git a/packages/app/e2e/helpers/no-truncation.ts b/packages/app/e2e/helpers/no-truncation.ts new file mode 100644 index 000000000..47a09295c --- /dev/null +++ b/packages/app/e2e/helpers/no-truncation.ts @@ -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 { + 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(); + const failures: TruncatedLabel[] = []; + + for (const root of elements) { + if (!(root instanceof HTMLElement) || !isVisible(root)) continue; + const candidates = [root, ...Array.from(root.querySelectorAll("*"))]; + + 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([]); +} diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/helpers/schedule-fake-host.ts index f4dad75ad..3f4946801 100644 --- a/packages/app/e2e/helpers/schedule-fake-host.ts +++ b/packages/app/e2e/helpers/schedule-fake-host.ts @@ -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 & { type?: string; requestId?: string }; @@ -20,6 +20,31 @@ export interface FakeScheduleHostWorkspace { workspace: Record; } +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; + schedules?: FakeScheduleSummary[]; }): Promise { 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, }, }, ); diff --git a/packages/app/e2e/helpers/settled.ts b/packages/app/e2e/helpers/settled.ts new file mode 100644 index 000000000..c2fdacddf --- /dev/null +++ b/packages/app/e2e/helpers/settled.ts @@ -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 { + 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 { + 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 { + const samples = await collectSamples(locator, options); + assertSettledText(samples); + assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE); +} + +export async function expectStableHeight( + locator: Locator, + options?: SettledOptions, +): Promise { + const samples = await collectSamples(locator, options); + assertStableHeight(samples, options?.heightTolerance ?? DEFAULT_HEIGHT_TOLERANCE); +} diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts index ff45fb383..fa184ae0a 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -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 { 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 { await expect(searchInput).not.toBeVisible({ timeout: 5_000 }); } +async function expectThinkingOptionsFit(page: Page): Promise { + 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; }> { @@ -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"); diff --git a/packages/app/e2e/new-workspace-isolation-memory.spec.ts b/packages/app/e2e/new-workspace-isolation-memory.spec.ts index 6e90589fd..4f9cc0669 100644 --- a/packages/app/e2e/new-workspace-isolation-memory.spec.ts +++ b/packages/app/e2e/new-workspace-isolation-memory.spec.ts @@ -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); diff --git a/packages/app/e2e/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/schedules-edit-model-hydration.spec.ts index b9d1f6f68..0a706b0e2 100644 --- a/packages/app/e2e/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/schedules-edit-model-hydration.spec.ts @@ -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[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> = []; @@ -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); }); }); diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts index c1bfa16e9..836dbbb24 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -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 { 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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> = []; @@ -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", + }); + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 9b9cc8b88..2290cfecf 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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, diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 15b33314d..57e424cba 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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) => ( @@ -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 ( + {desktopContent} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 9c6ca1996..c11a11a18 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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 ; +} + +function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { + const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]); + return ; +} + +function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { + const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); + return ; +} + 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, theme.iconSize.sm, theme.colors.foregroundMuted], + () => , + [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 ( - - ); - }} + {({ hovered }) => } ) : 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 = ( - + + + {t("modelSelector.loadingShort")} ); } else { stateNode = ( - + {t("modelSelector.error")} ); @@ -346,11 +380,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps return ( - + {provider.label} {stateNode} - + ); @@ -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 ( - + {message} {onRetryProvider ? ( ), - [canSubmit, handleSubmitPress, isEdit, isSubmitting, onClose], + [canSubmit, handleSubmitPress, isSubmitting, mode, onClose], ); return ( @@ -475,327 +430,494 @@ export function ScheduleFormSheet({ header={header} visible={visible} onClose={onClose} + onDismiss={onDismiss} footer={footer} webScrollbar testID="schedule-form-sheet" > - - Name - + + ); +} + +interface ScheduleFormFieldsProps { + model: ScheduleFormModel; + state: ScheduleFormState; + providerSnapshot: ReturnType; + agentTargetLabel: string | null; + controlSize: FieldControlSize; + cadenceError: string | null; + mutationServerId: string; +} + +function ScheduleFormFields({ + model, + state, + providerSnapshot, + agentTargetLabel, + controlSize, + cadenceError, + mutationServerId, +}: ScheduleFormFieldsProps): ReactElement { + return ( + <> + + - + - - Prompt - + - + - {isAgentTarget ? ( - - Target - - - {agentTargetLabel} - - - Runs against this existing agent. - - ) : ( - <> - - Project - - + - - Model - - + - {modeOptions.length > 0 ? ( - - ) : null} - - )} - - - Cadence - - - - - Max runs - + - Leave blank to run indefinitely - + - {submitError ? {submitError} : null} - - ); -} - -// --------------------------------------------------------------------------- -// Mode field - Combobox over the selected provider's modes. -// --------------------------------------------------------------------------- - -function ModeField({ - options, - selectedMode, - onSelect, -}: { - options: { id: string; label: string }[]; - selectedMode: string; - onSelect: (modeId: string) => void; -}): ReactElement { - const anchorRef = useRef(null); - const [open, setOpen] = useState(false); - - const comboboxOptions = useMemo( - () => options.map((option) => ({ id: option.id, label: option.label })), - [options], - ); - - const selectedLabel = - options.find((option) => option.id === selectedMode)?.label ?? "Default mode"; - - const handleSelect = useCallback( - (id: string) => { - onSelect(id); - setOpen(false); - }, - [onSelect], - ); - - const handlePress = useCallback(() => { - setOpen((current) => !current); - }, []); - - const triggerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.selectTrigger, - (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, - ], - [open], - ); - - return ( - - Mode - - - - {selectedLabel} - - - - - 6} - title="Select mode" - open={open} - onOpenChange={setOpen} - anchorRef={anchorRef} - desktopPlacement="bottom-start" - /> - - ); -} - -function ProjectField({ - options, - targetByOptionId, - value, - selectedTarget, - fallbackCwd, - onSelect, -}: { - options: ComboboxOption[]; - targetByOptionId: Map; - value: string; - selectedTarget: ScheduleProjectTarget | null; - /** Stored cwd for an edited schedule whose path matches no known project. */ - fallbackCwd: string; - onSelect: (target: ScheduleProjectTarget) => void; -}): ReactElement { - const anchorRef = useRef(null); - const [open, setOpen] = useState(false); - - const handleSelect = useCallback( - (id: string) => { - const target = targetByOptionId.get(id); - if (!target) { - return; - } - onSelect(target); - setOpen(false); - }, - [onSelect, targetByOptionId], - ); - - const handlePress = useCallback(() => { - setOpen((current) => !current); - }, []); - - const triggerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.selectTrigger, - (Boolean(hovered) || pressed || open) && styles.selectTriggerActive, - ], - [open], - ); - - // Honest hydration: a stored cwd that matches no known project shows the - // shortened path itself (not the blank "Select project"), and stays put until - // the user deliberately picks a project. - const storedPath = fallbackCwd.trim(); - const displayValue = - selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project"); - const isPlaceholder = !selectedTarget && !storedPath; - const description = selectedTarget - ? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}` - : null; - - const renderOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ( - - ), - [], - ); - - return ( - <> - - - - {displayValue} - - - - - {description ? {description} : null} - + {state.submitError ? {state.submitError} : null} ); } -function ProjectOptionItem({ - option, - selected, - active, - onPress, +interface ScheduleTargetFieldsProps { + model: ScheduleFormModel; + state: ScheduleFormState; + providerSnapshot: ReturnType; + agentTargetLabel: string | null; + controlSize: FieldControlSize; + mutationServerId: string; +} + +function ScheduleTargetFields({ + model, + state, + providerSnapshot, + agentTargetLabel, + controlSize, + mutationServerId, +}: ScheduleTargetFieldsProps): ReactElement { + const hostOptions = useMemo[]>( + () => + state.hosts.map((host) => ({ + id: host.serverId, + value: host.serverId, + label: host.label, + testID: buildScheduleHostOptionTestId(host.serverId), + })), + [state.hosts], + ); + const selectedHost = state.hosts.find((host) => host.serverId === state.selectedServerId) ?? null; + const selectedHostDisplay = useMemo(() => { + if (selectedHost) { + return { label: selectedHost.label }; + } + if (state.selectedServerId) { + return { label: state.selectedServerId }; + } + return null; + }, [selectedHost, state.selectedServerId]); + const projectOptions = state.projectOptions; + const modeOptions = useMemo[]>( + () => + state.modeOptions.map((option) => ({ + id: option.id, + value: option.id, + label: option.label, + })), + [state.modeOptions], + ); + const thinkingOptions = useMemo[]>( + () => + state.availableThinkingOptions.map((option) => ({ + id: option.id, + value: option.id, + label: formatThinkingOptionLabel(option), + testID: buildThinkingOptionTestId(option.id), + })), + [state.availableThinkingOptions], + ); + const handleSelectHost = useCallback( + (nextServerId: string) => { + model.setHost(nextServerId); + }, + [model], + ); + const handleSelectProject = useCallback( + (optionId: string, display: ScheduleFormDisplay) => { + model.setProject(optionId, display); + }, + [model], + ); + const handleSelectModel = useCallback( + (provider: AgentProvider, modelId: string) => { + model.setModel(provider, modelId); + }, + [model], + ); + const handleSelectMode = useCallback( + (modeId: string) => { + model.setSessionMode(modeId); + }, + [model], + ); + const handleSelectThinking = useCallback( + (thinkingOptionId: string) => { + model.setThinking(thinkingOptionId); + }, + [model], + ); + const handleModelOpen = useCallback(() => { + providerSnapshot.refetchIfStale(state.selectedProvider); + }, [providerSnapshot, state.selectedProvider]); + const handleRetryProvider = useCallback( + (provider: AgentProvider) => { + void providerSnapshot.refresh([provider]); + }, + [providerSnapshot], + ); + const renderHostOption = useCallback( + (input: SelectFieldRenderOptionInput) => , + [], + ); + const renderProjectOption = useCallback( + (input: SelectFieldRenderOptionInput) => , + [], + ); + const renderThinkingOption = useCallback( + (input: SelectFieldRenderOptionInput) => , + [], + ); + const modelTriggerLeading = useMemo( + () => , + [state.selectedProvider], + ); + const renderModelTrigger = useCallback( + ({ + selectedModelLabel, + disabled, + isOpen, + hovered, + pressed, + }: { + selectedModelLabel: string; + onPress: () => void; + disabled: boolean; + isOpen: boolean; + hovered: boolean; + pressed: boolean; + }): ReactNode => { + const displayLabel = state.selectedModelDisplay?.label ?? selectedModelLabel; + return ( + + ); + }, + [controlSize, modelTriggerLeading, state.selectedModel, state.selectedModelDisplay], + ); + + if (state.targetKind === "agent") { + return ; + } + + return ( + <> + {state.mode === "edit" || state.hosts.length > 1 ? ( + + ) : null} + + {state.disclosure.showProjectField ? ( + + ) : null} + + {state.disclosure.showModelField ? ( + + + + ) : null} + + {state.disclosure.showThinkingField ? ( + 6} + title="Select thinking" + size={controlSize} + triggerTestID="schedule-thinking-trigger" + renderOption={renderThinkingOption} + /> + ) : null} + + {state.disclosure.showModeField ? ( + 6} + title="Select mode" + size={controlSize} + triggerTestID="schedule-mode-trigger" + /> + ) : null} + + {state.disclosure.showIsolationField ? ( + + ) : null} + + {state.disclosure.showArchiveOnFinishField ? ( + + + + ) : null} + + ); +} + +function ScheduleIsolationField({ + model, + state, + size, }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; + model: ScheduleFormModel; + state: ScheduleFormState; + size: FieldControlSize; }): ReactElement { - const leadingSlot = useMemo( + const options = useMemo[]>( + () => [ + { + id: "local", + value: "local", + label: "Local", + testID: "schedule-isolation-local", + }, + { + id: "worktree", + value: "worktree", + label: "Worktree", + testID: "schedule-isolation-worktree", + }, + ], + [], + ); + const selectedDisplay = useMemo( + () => ({ label: state.effectiveIsolation === "worktree" ? "Worktree" : "Local" }), + [state.effectiveIsolation], + ); + const triggerLeading = useMemo( () => ( - + {state.effectiveIsolation === "worktree" ? ( + + ) : ( + + )} ), + [state.effectiveIsolation], + ); + const handleSelectIsolation = useCallback( + (value: "local" | "worktree") => { + model.setIsolation(value); + }, + [model], + ); + const renderIsolationOption = useCallback( + (input: SelectFieldRenderOptionInput<"local" | "worktree">) => ( + + ), [], ); + return ( + + ); +} + +function ScheduleAgentTargetField({ + label, + size, +}: { + label: string | null; + size: FieldControlSize; +}): ReactElement { + const fieldStyle = useMemo( + () => [styles.readonlyField, size === "sm" ? styles.readonlyFieldSm : styles.readonlyFieldMd], + [size], + ); + const textStyle = useMemo( + () => [styles.readonlyText, size === "sm" ? styles.readonlyTextSm : styles.readonlyTextMd], + [size], + ); + + return ( + + + + {label} + + + + ); +} + +function IsolationOptionItem({ + option, + selected, + active, + onPress, +}: SelectFieldRenderOptionInput<"local" | "worktree">): ReactElement { + const leadingSlot = useMemo( + () => ( + + {option.value === "worktree" ? ( + + ) : ( + + )} + + ), + [option.value], + ); + return ( ): ReactElement { + const leadingSlot = useMemo(() => , [option.value]); + + return ( + + ); +} + +function ProjectOptionItem({ + option, + selected, + active, + onPress, +}: SelectFieldRenderOptionInput): ReactElement { + const leadingSlot = useMemo( + () => ( + + + + ), + [], + ); + + return ( + + ); +} + +function ThinkingOptionItem({ + option, + selected, + active, + onPress, +}: SelectFieldRenderOptionInput): ReactElement { + const leadingSlot = useMemo( + () => ( + + + + ), + [], + ); + + return ( + + ); +} -/** Dynamic provider glyph - reads its color off a StyleSheet object so the - * runtime-resolved component stays compliant without useUnistyles. */ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { if (!provider) { return null; @@ -818,143 +1008,57 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement return ; } -// Non-interactive field rendered inside CombinedModelSelector's trigger (with -// triggerFill). The selector's outer Pressable owns press/hover; this leaf just -// paints the field and reads `active` for the focus border. -function ModelTrigger({ - label, - provider, - disabled, - active, - isPlaceholder, -}: { - label: string; - provider: string | null; - disabled: boolean; - active: boolean; - isPlaceholder: boolean; -}): ReactElement { - const containerStyle = useMemo( - () => [ - styles.selectTrigger, - active && styles.selectTriggerActive, - disabled && styles.selectTriggerDisabled, - ], - [active, disabled], - ); - return ( - - - - {label} - - - - ); -} +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); -const styles = StyleSheet.create((theme) => ({ - field: { - gap: theme.spacing[2], - }, - label: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - input: { - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - multilineInput: { - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - minHeight: 96, - }, - hint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, - }, - readonlyField: { - flexDirection: "row", - alignItems: "center", - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - minHeight: 44, - }, - selectTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - minHeight: 44, - }, - selectTriggerActive: { - borderColor: theme.colors.borderAccent, - }, - selectTriggerDisabled: { - opacity: theme.opacity[50], - }, - selectTriggerText: { - flex: 1, - minWidth: 0, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - selectTriggerPlaceholder: { - flex: 1, - minWidth: 0, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, - optionIconBox: { - width: 18, - height: 18, - alignItems: "center", - justifyContent: "center", - }, - footer: { - flex: 1, - flexDirection: "row", - gap: theme.spacing[3], - }, - footerButton: { - flex: 1, - }, - // Static color holders read by the dynamic provider icon + chevron (compliant - // idiom - no useUnistyles in render). - providerIcon: { - color: theme.colors.foregroundMuted, - }, - chevron: { - color: theme.colors.foregroundMuted, - }, -})); + return { + multilineInput: { + minHeight: 96, + }, + readonlyField: { + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.surface2, + borderWidth: 1, + borderColor: theme.colors.border, + }, + readonlyFieldSm: { + ...geometry.formTextInputSm, + }, + readonlyFieldMd: { + ...geometry.formTextInputMd, + }, + readonlyText: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + }, + readonlyTextSm: { + fontSize: theme.fontSize.sm, + }, + readonlyTextMd: { + fontSize: theme.fontSize.base, + }, + optionIconBox: { + width: 18, + height: 18, + alignItems: "center", + justifyContent: "center", + }, + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + submitError: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, + providerIcon: { + color: theme.colors.foregroundMuted, + }, + }; +}); diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 51f8abd68..3a7a275db 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -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 = { 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 ( + + + + ); + } + + if (!leftIcon) return null; + + if (typeof leftIcon === "object" && "type" in leftIcon) { + return {leftIcon}; + } + + if ( + typeof leftIcon === "function" && + !leftIcon.prototype?.isReactComponent && + leftIcon.length > 0 + ) { + return {(leftIcon as (color: string) => ReactElement)(iconColor)}; + } + + const Icon = leftIcon as ComponentType<{ color: string; size: number }>; + return ( + + + + ); +} + +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 & { @@ -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 ( - - - - ); - } - - if (!leftIcon) return null; - - // Pre-rendered element — pass through - if (typeof leftIcon === "object" && "type" in leftIcon) { - return {leftIcon}; - } - - const color = resolveIconColor(); - const iconSize = ICON_SIZE[size]; - - // Render function - if ( - typeof leftIcon === "function" && - !leftIcon.prototype?.isReactComponent && - leftIcon.length > 0 - ) { - return {(leftIcon as (color: string) => ReactElement)(color)}; - } - - // Component type - const Icon = leftIcon as ComponentType<{ color: string; size: number }>; - return ( - - - - ); + return foregroundIconMapping; } return ( @@ -249,7 +266,12 @@ export function Button({ onHoverOut={handleHoverOut} style={pressableStyle} > - {renderIcon()} + {children != null ? {children} : null} {trailing} diff --git a/packages/app/src/components/ui/combobox-frame-style.test.ts b/packages/app/src/components/ui/combobox-frame-style.test.ts new file mode 100644 index 000000000..84f884c4e --- /dev/null +++ b/packages/app/src/components/ui/combobox-frame-style.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/packages/app/src/components/ui/combobox-frame-style.ts b/packages/app/src/components/ui/combobox-frame-style.ts new file mode 100644 index 000000000..0e825abaf --- /dev/null +++ b/packages/app/src/components/ui/combobox-frame-style.ts @@ -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; + shouldHideDesktopContent: boolean; + availableHeight: number | undefined; +} + +export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp { + 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, + ]; +} diff --git a/packages/app/src/components/ui/combobox-trigger.tsx b/packages/app/src/components/ui/combobox-trigger.tsx index 0fbbb6714..7bb3683ae 100644 --- a/packages/app/src/components/ui/combobox-trigger.tsx +++ b/packages/app/src/components/ui/combobox-trigger.tsx @@ -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 | ((state: TriggerState) => StyleProp); @@ -36,23 +46,45 @@ interface ComboboxTriggerProps extends Omit(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) => { + setFocused(true); + onFocus?.(event); + }, + [onFocus], + ); + const handleBlur = useCallback( + (event: NativeSyntheticEvent) => { + setFocused(false); + onBlur?.(event); + }, + [onBlur], ); const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]); return ( - + {children} {chevron !== null && diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index edc92b10c..867685faf 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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 { - 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, diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts new file mode 100644 index 000000000..a167cfa5d --- /dev/null +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts new file mode 100644 index 000000000..584c58aca --- /dev/null +++ b/packages/app/src/components/ui/control-geometry.ts @@ -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; + controlHover: StyleProp; + controlActive: StyleProp; + controlDisabled?: StyleProp; +} + +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 = { + xs: ICON_SIZE.xs, + sm: ICON_SIZE.sm, + md: ICON_SIZE.md, + lg: ICON_SIZE.lg, +}; + +export const segmentedIconSize: Record = { + 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 { + 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, + }, + }; +} diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index a353807ea..d63d9a0a1 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -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 ( - + {error} ); } if (hint) { return ( - + {hint} ); @@ -36,17 +57,167 @@ export function FormField({ label, children, hint, error, testID }: FormFieldPro {label} {children} - {hintOrError} + {subtext} ); } -export const FormTextInput = forwardRef(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(style: TStyle): TStyle { + const cleanStyle: Record = {}; + for (const [key, value] of Object.entries(style as Record)) { + if (key.startsWith("unistyles_") || value === undefined) { + continue; + } + cleanStyle[key] = value; + } + return cleanStyle as TStyle; +} + +function assignTextInputRef(forwardedRef: ForwardedRef, node: TextInput | null): void { + if (typeof forwardedRef === "function") { + forwardedRef(node); + return; + } + if (forwardedRef) { + forwardedRef.current = node; + } +} + +export const FormTextInput = forwardRef(function FormTextInput( + { size = "md", style, onFocus, onBlur, editable, ...props }, ref, ) { - const inputStyle = useMemo(() => [formInputStyles.input, style], [style]); - return ; + 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>( + (event) => { + setFocused(true); + onFocus?.(event); + }, + [onFocus], + ); + const handleBlur = useCallback>( + (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 ( + + + + ); }); 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, + }, + }; +}); diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 934a6fa28..050a33d3e 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -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 { testID?: string; } +interface SegmentIconProps { + icon: SegmentedControlIconRenderer; + iconSize: number; + iconColor: string; +} + +function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) { + return {icon({ color: iconColor, size: iconSize })}; +} + +const ThemedSegmentIcon = withUnistyles(SegmentIcon); + +const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground }); +const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted }); + export function SegmentedControl({ options, value, @@ -34,11 +53,10 @@ export function SegmentedControl({ style, testID, }: SegmentedControlProps) { - 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({ {options.map((option) => { const isSelected = option.value === value; - const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; return ( ({ function SegmentItem({ option, isSelected, - iconColor, iconSize, hideLabels, segmentSizeStyle, @@ -83,7 +98,6 @@ function SegmentItem({ }: { option: SegmentedControlOption; isSelected: boolean; - iconColor: string; iconSize: number; hideLabels: boolean; segmentSizeStyle: StyleProp; @@ -119,15 +133,18 @@ function SegmentItem({ {option.icon ? ( - - {option.icon({ color: iconColor, size: iconSize })} - + ) : null} {hideLabels ? null : ( @@ -138,68 +155,68 @@ function SegmentItem({ ); } -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, + }, + }; +}); diff --git a/packages/app/src/components/ui/select-field.tsx b/packages/app/src/components/ui/select-field.tsx new file mode 100644 index 000000000..a1228cb8e --- /dev/null +++ b/packages/app/src/components/ui/select-field.tsx @@ -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 { + id: string; + value: TValue; + label: string; + description?: string; + kind?: ComboboxOption["kind"]; + testID?: string; +} + +export interface SelectFieldRenderOptionInput { + option: SelectFieldOption; + selected: boolean; + active: boolean; + onPress: () => void; +} + +export interface SelectFieldProps { + label: string; + value: TValue | null; + selectedDisplay: SelectFieldDisplay | null; + options: SelectFieldOption[]; + 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) => 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( + options: readonly SelectFieldOption[], + 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( + options: SelectFieldOption[], + loading: boolean, +): SelectFieldOption[] { + const previousOptionsRef = useRef[]>(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 ( + + {leading} + + {label} + + {loading ? ( + + + + ) : null} + + + ); +} + +export function SelectField({ + 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): ReactElement { + const anchorRef = useRef(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( + () => + 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) => { + setTriggerFocused(true); + }, []); + const handleTriggerBlur = useCallback((_event: NativeSyntheticEvent) => { + 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 ( + + ); + } + if (renderOption) { + return renderOption({ option: selectOption, selected, active, onPress }); + } + return ( + + ); + }, + [optionById, renderOption], + ); + + const displayLabel = selectedDisplay?.label ?? placeholder; + const fieldHint = selectedDisplay?.description ?? hint; + + const control = ( + <> + + + {({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => ( + + )} + + + + + ); + + if (!field) { + return {control}; + } + + return ( + + {control} + + ); +} + +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", + }, + }; +}); diff --git a/packages/app/src/components/ui/switch.test.tsx b/packages/app/src/components/ui/switch.test.tsx index 8f775d6da..1db67418f 100644 --- a/packages/app/src/components/ui/switch.test.tsx +++ b/packages/app/src/components/ui/switch.test.tsx @@ -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) => 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>, + mapping?: (theme: unknown) => object, + ) => + (props: Record) => + React.createElement(Component, { ...props, ...mapping?.(theme) }), })); describe("Switch", () => { diff --git a/packages/app/src/components/ui/switch.tsx b/packages/app/src/components/ui/switch.tsx index fb2c77b06..b4c035eea 100644 --- a/packages/app/src/components/ui/switch.tsx +++ b/packages/app/src/components/ui/switch.tsx @@ -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; } -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 ( + + + + ); +} + +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 ( - - - + ); } -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], + }, + }; +}); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 4fafa19a4..6e4024e39 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -805,6 +805,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onRetryProvider={onRetryModelProvider} isRetryingProvider={isRetryingModelProvider} serverId={modelSelectorServerId} + desktopPlacement="top-start" + desktopMinWidth={360} /> @@ -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 ? ( { it("normalizes blank cwd values to the home scope", () => { diff --git a/packages/app/src/hooks/providers-snapshot-query.ts b/packages/app/src/data/providers-snapshot.ts similarity index 100% rename from packages/app/src/hooks/providers-snapshot-query.ts rename to packages/app/src/data/providers-snapshot.ts diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts new file mode 100644 index 000000000..466ffabff --- /dev/null +++ b/packages/app/src/data/push-router.test.ts @@ -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; +type SubscribeCheckoutDiffResponseMessage = Extract< + SessionOutboundMessage, + { type: "subscribe_checkout_diff_response" } +>; +type StatusMessage = Extract; +type TerminalsChangedMessage = Extract; +type RouterMessage = + | ProvidersSnapshotUpdateMessage + | CheckoutDiffUpdateMessage + | SubscribeCheckoutDiffResponseMessage + | StatusMessage + | TerminalsChangedMessage; +type RouterMessageType = RouterMessage["type"]; +type RouterHandler = (message: RouterMessage) => void; +type RouterClient = Parameters[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: (message: Extract) => 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 = { + 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( + type: K, + handler: (message: Extract) => void, + ): () => void { + const routerHandler: RouterHandler = (message) => { + if (message.type === type) { + handler(message as Extract); + } + }; + handlers[type].push(routerHandler); + return () => { + handlers[type] = handlers[type].filter((candidate) => candidate !== routerHandler); + }; + } + + function emit(message: Extract): 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); + }); +}); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts new file mode 100644 index 000000000..b4dca8f11 --- /dev/null +++ b/packages/app/src/data/push-router.ts @@ -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; +type SubscribeCheckoutDiffResponseMessage = Extract< + SessionOutboundMessage, + { type: "subscribe_checkout_diff_response" } +>; +type StatusMessage = Extract; +type TerminalsChangedMessage = Extract; +type ServerDataEventType = + | "providers_snapshot_update" + | "checkout_diff_update" + | "subscribe_checkout_diff_response" + | "status" + | "terminals_changed"; +type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; +type CheckoutDiffCachePayload = Omit; +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 { + serverData: ServerDataRoute; +} + +export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; + +interface ServerDataPushClient { + on( + type: TType, + handler: (message: Extract) => void, + ): () => void; + subscribeCheckoutDiff( + cwd: string, + compare: CheckoutDiffCompare, + options: { subscriptionId: string; requestId?: string }, + ): Promise; + 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; + workspaceTerminals: Map; +} + +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 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(); + const activeTerminalSubscriptions = new Map(); + let disposed = false; + + function reconcileSubscriptions( + fallbackActive: ActiveServerDataSubscriptions = { + checkoutDiff: activeCheckoutDiffSubscriptions, + workspaceTerminals: activeTerminalSubscriptions, + }, + ): void { + if (disposed) { + return; + } + + const desiredCheckoutDiffSubscriptions = new Map(); + const desiredTerminalSubscriptions = new Map(); + 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; + client: ServerDataPushClient; + desired: Map; + 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; + client: ServerDataPushClient; + desired: Map; +}): 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( + daemonConfigQueryKey(input.serverId), + payload.config, + ); +} + +function applyCheckoutDiffUpdate(input: { + activeCheckoutDiffSubscriptions: Map; + 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; + 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; + 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(query.queryKey, input.payload); + } +} + +function applyTerminalsChanged(input: { + activeCheckoutDiffSubscriptions: Map; + activeTerminalSubscriptions: Map; + 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(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; + 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; + 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): 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 { + 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); +} diff --git a/packages/app/src/query/query-client.ts b/packages/app/src/data/query-client.ts similarity index 100% rename from packages/app/src/query/query-client.ts rename to packages/app/src/data/query-client.ts diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts new file mode 100644 index 000000000..b656bd836 --- /dev/null +++ b/packages/app/src/data/query.ts @@ -0,0 +1,106 @@ +import { + keepPreviousData, + skipToken, + useQuery, + type QueryKey, + type UseQueryOptions, + type UseQueryResult, +} from "@tanstack/react-query"; + +type QueryFnOption = NonNullable< + UseQueryOptions["queryFn"] +>; + +type ReplicaQueryInput = Omit< + UseQueryOptions, + | "gcTime" + | "initialData" + | "refetchOnMount" + | "refetchOnReconnect" + | "refetchOnWindowFocus" + | "staleTime" +> & { + pushEvent: string; +}; + +type FetchQueryInput = Omit< + UseQueryOptions, + "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" +> & { + dataShape: "list" | "value"; + queryFn: QueryFnOption; + staleTimeMs: number; +}; + +export function useReplicaQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(input: ReplicaQueryInput): UseQueryResult { + return useQuery(replicaQueryOptions(input)); +} + +export function useFetchQuery< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(input: FetchQueryInput): UseQueryResult { + return useQuery(fetchQueryOptions(input)); +} + +function replicaQueryOptions< + TQueryFnData, + TError = Error, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + input: ReplicaQueryInput, +): UseQueryOptions { + 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, +): UseQueryOptions { + 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, + }; +} diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index e60913fcf..a2717344a 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -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 { diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 0b7ed61f1..63e40bc8d 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -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, diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 393e725d9..4ab14c695 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -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({ + const query = useReplicaQuery({ 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(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(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(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; diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index bb780ad04..e20c6a90b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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(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, diff --git a/packages/app/src/hooks/use-daemon-config.ts b/packages/app/src/hooks/use-daemon-config.ts index 422f5d540..5f32a1f83 100644 --- a/packages/app/src/hooks/use-daemon-config.ts +++ b/packages/app/src/hooks/use-daemon-config.ts @@ -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) { diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 409cc8fac..7dabc0315 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -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, ], ); diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 1aebdfb52..13c480315 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -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>; - -interface HostFixture { +function runtimeState(input: { serverId: string; - serverName: string; - status: "online" | "offline" | "missing-snapshot"; - workspaces?: WorkspaceDescriptorPayload[] | Error; -} - -interface RuntimeAdapter extends ProjectsRuntime { - fetchCalls: Map; -} - -function createRuntime(hosts: HostFixture[]): RuntimeAdapter { - const snapshots = new Map(); - const clients = new Map | null>(); - const fetchCalls = new Map(); - - 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", diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index b5b038b4b..61a17a03b 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -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) => 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([]); + const runtimeSnapshotTick = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), () => runtime.getVersion(), ); - const hostInputs = useMemo( - () => - 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, }; } diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index a28ec6ce8..73019e421 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -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>; @@ -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: { diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index b8a25c878..676baf20c 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -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>; type RefreshProvidersSnapshotResult = Awaited>; @@ -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 }), }); } diff --git a/packages/app/src/hooks/use-schedule-mutations.test.ts b/packages/app/src/hooks/use-schedule-mutations.test.ts new file mode 100644 index 000000000..c72b7e806 --- /dev/null +++ b/packages/app/src/hooks/use-schedule-mutations.test.ts @@ -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 { + 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( + { queryKey: schedulesQueryBaseKey }, + pauseAggregatedSchedules, + ); + }).not.toThrow(); + + expect(queryClient.getQueryData(connectingKey)).toEqual(connecting); + expect(queryClient.getQueryData(loadedKey)).toEqual({ + status: "loaded", + data: [schedule({ status: "paused" })], + hostErrors: [], + }); + }); +}); diff --git a/packages/app/src/hooks/use-schedule-mutations.ts b/packages/app/src/hooks/use-schedule-mutations.ts index e419f6a66..9c0ff9d19 100644 --- a/packages/app/src/hooks/use-schedule-mutations.ts +++ b/packages/app/src/hooks/use-schedule-mutations.ts @@ -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; @@ -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({ + previous: queryClient.getQueriesData({ queryKey: schedulesQueryBaseKey, }), }; @@ -67,14 +77,9 @@ function updateSchedulesData( queryClient: QueryClient, updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[], ): void { - queryClient.setQueriesData( + queryClient.setQueriesData( { queryKey: schedulesQueryBaseKey }, - (current) => { - if (!current) { - return current; - } - return { ...current, schedules: updateSchedules(current.schedules) }; - }, + (current) => updateAggregatedSchedulesData(current, updateSchedules), ); } diff --git a/packages/app/src/hooks/use-schedules.test.ts b/packages/app/src/hooks/use-schedules.test.ts new file mode 100644 index 000000000..4721ab13f --- /dev/null +++ b/packages/app/src/hooks/use-schedules.test.ts @@ -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"]); + }); +}); diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts index d0b945850..e83d87c1a 100644 --- a/packages/app/src/hooks/use-schedules.ts +++ b/packages/app/src/hooks/use-schedules.ts @@ -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; 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( () => 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; + 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: () => { diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 19c9cb9fe..f492fac52 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -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, diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 88b868398..8d406b66e 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -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"; diff --git a/packages/app/src/projects/aggregated-projects.ts b/packages/app/src/projects/aggregated-projects.ts deleted file mode 100644 index e39f0ca4e..000000000 --- a/packages/app/src/projects/aggregated-projects.ts +++ /dev/null @@ -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 | 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 { - const results = await Promise.all( - input.hosts.map(async (host): Promise => { - 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, - }; -} diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index d000edb00..94417485b 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -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({ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index e11bfffca..f6ecb32c9 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -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 ?? diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 1f5acef8c..992c3a8f0 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -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 = {}, modified: Partial = {}, + 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"); }); }); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index e6c7303ba..196b16bc2 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -37,9 +37,14 @@ export interface UserModifiedFields { workingDir: boolean; } +export type ProviderModelsByProvider = Map; + +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[AgentProvider]; export const RESOLVABLE_PROVIDER_STATUSES = new Set([ @@ -60,11 +68,12 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set(["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; } | { 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; + 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, +): 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"); } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 8cee8a3b0..283354231 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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 diff --git a/packages/app/src/schedules/aggregated-schedules.test.ts b/packages/app/src/schedules/aggregated-schedules.test.ts new file mode 100644 index 000000000..74961c29d --- /dev/null +++ b/packages/app/src/schedules/aggregated-schedules.test.ts @@ -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 { + 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; + schedules?: Record; +}): 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: [], + }); + }); +}); diff --git a/packages/app/src/schedules/aggregated-schedules.ts b/packages/app/src/schedules/aggregated-schedules.ts index bf94d7eca..936b1311b 100644 --- a/packages/app/src/schedules/aggregated-schedules.ts +++ b/packages/app/src/schedules/aggregated-schedules.ts @@ -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 = + | { 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 { +): Promise { + 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"; } diff --git a/packages/app/src/schedules/schedule-cadence-options.test.ts b/packages/app/src/schedules/schedule-cadence-options.test.ts new file mode 100644 index 000000000..2c7b83bd5 --- /dev/null +++ b/packages/app/src/schedules/schedule-cadence-options.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/schedules/schedule-cadence-options.ts b/packages/app/src/schedules/schedule-cadence-options.ts new file mode 100644 index 000000000..41d7060af --- /dev/null +++ b/packages/app/src/schedules/schedule-cadence-options.ts @@ -0,0 +1,62 @@ +import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types"; +import { everyMsToParts } from "@/utils/schedule-format"; + +type CronCadence = Extract; + +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)} * *`; +} diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts new file mode 100644 index 000000000..ef490e857 --- /dev/null +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -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) { + return openScheduleForm({ + ...snapshot, + hosts: HOSTS, + }); +} + +function openWithHosts(snapshot: ScheduleFormSnapshot) { + return openScheduleForm(snapshot); +} + +function applyPreferences(form: ReturnType, 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", + }); + }); +}); diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts new file mode 100644 index 000000000..39d4b66b3 --- /dev/null +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -0,0 +1,1054 @@ +import type { + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; +import { formatThinkingOptionLabel } from "@/composer/agent-controls/utils"; +import { + buildSelectableProviderSelectorProviders, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { + buildProviderDefinitionMapForStatuses, + INITIAL_USER_MODIFIED, + RESOLVABLE_PROVIDER_STATUSES, + resolveDefaultModelId, + resolveFormStateFromProviderModels, + resolveThinkingOptionId, + type FormInitialValues, + type FormState, + type ProviderModelsByProvider, + type UserModifiedFields, +} from "@/provider-selection/resolve-agent-form"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; +import { shortenPath } from "@/utils/shorten-path"; +import { normalizeScheduleFormCadence } from "./schedule-cadence-options"; +import { PROJECT_OPTION_PREFIX, type ScheduleProjectTarget } from "./schedule-project-targets"; + +export interface ScheduleFormDisplay { + label: string; + description?: string; +} + +export interface ScheduleFormHost { + serverId: string; + label: string; + supportsWorkspaceMultiplicity?: boolean; +} + +export interface ScheduleFormSnapshot { + mode: "create" | "edit"; + schedule?: ScheduleSummary & { serverId?: string; serverName?: string }; + hosts: readonly ScheduleFormHost[]; + defaults: { + serverId?: string | null; + projectTargets: readonly ScheduleProjectTarget[]; + preferences?: FormPreferences; + timezone?: string; + }; +} + +export interface ScheduleFormProviderSnapshot { + entries: ProviderSnapshotEntry[]; +} + +export interface ScheduleDisclosureState { + showProjectField: boolean; + showModelField: boolean; + showThinkingField: boolean; + showModeField: boolean; + showIsolationField: boolean; + showArchiveOnFinishField: boolean; +} + +export interface ScheduleProviderSnapshotRequest { + serverId: string; + cwd: string; +} + +export interface ScheduleFormProjectOption { + id: string; + value: string; + label: string; + testID: string; +} + +export type ScheduleFormTargetKind = "agent" | "new-agent"; +type ProviderResolutionStatus = "idle" | "pending" | "complete"; + +export interface ScheduleFormState { + mode: "create" | "edit"; + targetKind: ScheduleFormTargetKind; + name: string; + prompt: string; + maxRuns: string; + cadence: ScheduleCadence; + submitCadence: ScheduleCadence; + hosts: ScheduleFormHost[]; + projectOptions: ScheduleFormProjectOption[]; + selectedServerId: string | null; + selectedProvider: AgentProvider | null; + selectedModel: string; + selectedMode: string; + selectedThinkingOptionId: string; + workingDir: string; + projectDisplay: ScheduleFormDisplay | null; + selectedProjectOptionId: string; + selectedModelDisplay: ScheduleFormDisplay | null; + selectedModeDisplay: ScheduleFormDisplay; + selectedThinkingDisplay: ScheduleFormDisplay | null; + modelSelectorProviders: ProviderSelectorProvider[]; + modeOptions: AgentMode[]; + availableThinkingOptions: NonNullable; + archiveOnFinish: boolean; + isolation: "local" | "worktree"; + effectiveIsolation: "local" | "worktree"; + submitArchiveOnFinish: boolean | undefined; + submitIsolation: "local" | "worktree" | undefined; + canUseWorktreeIsolation: boolean; + providerResolutionByServerId: Record; + providerSnapshotRequest: ScheduleProviderSnapshotRequest | null; + disclosure: ScheduleDisclosureState; + canSubmit: boolean; + submitError: string | null; +} + +export interface ScheduleFormModel { + getState: () => ScheduleFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + applyHosts: (hosts: readonly ScheduleFormHost[]) => void; + applyProjectTargets: (targets: readonly ScheduleProjectTarget[]) => void; + applyPreferences: (preferences: FormPreferences | undefined) => void; + applyProviderSnapshot: (serverId: string, snapshot: ScheduleFormProviderSnapshot) => void; + setHost: (serverId: string | null) => void; + setProject: (optionId: string, display: ScheduleFormDisplay) => void; + setModel: (provider: AgentProvider, modelId: string) => void; + setThinking: (thinkingOptionId: string) => void; + setSessionMode: (modeId: string) => void; + setName: (value: string) => void; + setPrompt: (value: string) => void; + setMaxRuns: (value: string) => void; + setCadence: (value: ScheduleCadence) => void; + setIsolation: (value: "local" | "worktree") => void; + setArchiveOnFinish: (value: boolean) => void; + setSubmitError: (value: string | null) => void; +} + +const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 }; +const DEFAULT_TIMEZONE = "UTC"; + +type ThinkingOption = NonNullable[number]; + +function newAgentConfig(schedule: ScheduleFormSnapshot["schedule"]) { + if (schedule?.target.type === "new-agent") { + return schedule.target.config; + } + return null; +} + +function buildProjectOptionTestId(optionId: string): string { + const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, ""); + return `schedule-project-option-${targetKey}`; +} + +function buildProjectDisplay(target: ScheduleProjectTarget): ScheduleFormDisplay { + return { label: target.projectName }; +} + +function buildStoredProjectDisplay(cwd: string): ScheduleFormDisplay | null { + const storedPath = cwd.trim(); + if (!storedPath) { + return null; + } + return { label: shortenPath(storedPath) }; +} + +function buildProjectOptions( + targets: readonly ScheduleProjectTarget[], + serverId: string | null, +): ScheduleFormProjectOption[] { + if (!serverId) { + return []; + } + return targets + .filter((target) => target.serverId === serverId) + .map((target) => ({ + id: target.optionId, + value: target.optionId, + label: target.projectName, + testID: buildProjectOptionTestId(target.optionId), + })); +} + +function resolveProjectTarget(input: { + targets: readonly ScheduleProjectTarget[]; + serverId: string | null; + cwd: string; +}): ScheduleProjectTarget | null { + const cwd = input.cwd.trim(); + if (!input.serverId || !cwd) { + return null; + } + return ( + input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null + ); +} + +function findProjectTargetByOptionId( + targets: readonly ScheduleProjectTarget[], + optionId: string, +): ScheduleProjectTarget | null { + return targets.find((target) => target.optionId === optionId) ?? null; +} + +function resolveProjectDisplay(input: { + targets: readonly ScheduleProjectTarget[]; + serverId: string | null; + cwd: string; +}): ScheduleFormDisplay | null { + const target = resolveProjectTarget(input); + if (target) { + return buildProjectDisplay(target); + } + return buildStoredProjectDisplay(input.cwd); +} + +function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider { + const map: ProviderModelsByProvider = new Map(); + for (const entry of entries) { + map.set(entry.provider, entry.models ?? null); + } + return map; +} + +function resolveSelectedEntry( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, +): ProviderSnapshotEntry | null { + if (!provider) { + return null; + } + return entries.find((entry) => entry.provider === provider) ?? null; +} + +function resolveModeOptions( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, +): AgentMode[] { + return resolveSelectedEntry(entries, provider)?.modes ?? []; +} + +function resolveAvailableModels( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, +): AgentModelDefinition[] | null { + return resolveSelectedEntry(entries, provider)?.models ?? null; +} + +function resolveEffectiveModel( + models: AgentModelDefinition[] | null, + modelId: string, +): AgentModelDefinition | null { + const selectedModelId = modelId.trim(); + if (!models || !selectedModelId) { + return null; + } + return ( + models.find((model) => model.id === selectedModelId) ?? + models.find((model) => model.isDefault) ?? + models[0] ?? + null + ); +} + +function resolveThinkingOptions( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, + modelId: string, +): NonNullable { + const model = resolveEffectiveModel(resolveAvailableModels(entries, provider), modelId); + return model?.thinkingOptions ?? []; +} + +function resolveModelDisplay(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider | null; + modelId: string; +}): ScheduleFormDisplay | null { + const modelId = input.modelId.trim(); + if (!modelId) { + return null; + } + const model = resolveEffectiveModel( + resolveAvailableModels(input.entries, input.provider), + modelId, + ); + return { label: model?.label ?? modelId }; +} + +function resolveModeDisplay(input: { + modeOptions: readonly AgentMode[]; + modeId: string; +}): ScheduleFormDisplay { + const modeId = input.modeId.trim(); + if (!modeId) { + return { label: "Default mode" }; + } + return { label: input.modeOptions.find((mode) => mode.id === modeId)?.label ?? modeId }; +} + +function resolveThinkingDisplay(input: { + options: readonly ThinkingOption[]; + thinkingOptionId: string; +}): ScheduleFormDisplay | null { + const thinkingOptionId = input.thinkingOptionId.trim(); + if (!thinkingOptionId) { + return null; + } + const option = input.options.find((entry) => entry.id === thinkingOptionId) ?? { + id: thinkingOptionId, + }; + return { label: formatThinkingOptionLabel(option) }; +} + +function isSelectedModelValidForProviders(input: { + providers: readonly ProviderSelectorProvider[]; + selectedProvider: AgentProvider | null; + selectedModel: string; +}): boolean { + if (!input.selectedProvider) { + return false; + } + const provider = input.providers.find((entry) => entry.id === input.selectedProvider); + if (!provider || provider.modelSelection.kind !== "models") { + return false; + } + const selectedModel = input.selectedModel.trim(); + if (!selectedModel) { + return true; + } + return provider.modelSelection.rows.some((row) => row.modelId === selectedModel); +} + +function normalizeInitialValues(input: { + snapshot: ScheduleFormSnapshot; + selectedServerId: string | null; +}): FormInitialValues | undefined { + const config = newAgentConfig(input.snapshot.schedule); + if (!config) { + return undefined; + } + return { + serverId: input.selectedServerId, + provider: config.provider, + model: config.model ?? null, + modeId: config.modeId ?? null, + thinkingOptionId: config.thinkingOptionId ?? null, + workingDir: config.cwd, + }; +} + +function resolveInitialServerId(snapshot: ScheduleFormSnapshot): string | null { + if (snapshot.mode === "edit") { + return snapshot.schedule?.serverId ?? snapshot.defaults.serverId ?? null; + } + if (snapshot.defaults.serverId !== undefined) { + return snapshot.defaults.serverId; + } + if (snapshot.hosts.length === 1) { + return snapshot.hosts[0]?.serverId ?? null; + } + return null; +} + +function makeProviderResolutionRecord( + serverId: string | null, +): Record { + if (!serverId) { + return {}; + } + return { [serverId]: "pending" }; +} + +function resolveTargetKind(snapshot: ScheduleFormSnapshot): ScheduleFormTargetKind { + if (snapshot.mode === "edit" && snapshot.schedule?.target.type === "agent") { + return "agent"; + } + return "new-agent"; +} + +function buildProviderSnapshotRequest(input: { + targetKind: ScheduleFormTargetKind; + selectedServerId: string | null; + workingDir: string; +}): ScheduleProviderSnapshotRequest | null { + if (input.targetKind !== "new-agent" || !input.selectedServerId || !input.workingDir.trim()) { + return null; + } + return { serverId: input.selectedServerId, cwd: input.workingDir }; +} + +function buildInitialProjectDisplay(input: { + config: ReturnType; + targets: readonly ScheduleProjectTarget[]; + selectedServerId: string | null; +}): ScheduleFormDisplay | null { + if (!input.config) { + return null; + } + return resolveProjectDisplay({ + targets: input.targets, + serverId: input.selectedServerId, + cwd: input.config.cwd, + }); +} + +function buildInitialModelDisplay(modelId: string): ScheduleFormDisplay | null { + if (!modelId) { + return null; + } + return { label: modelId }; +} + +function buildInitialModeDisplay(modeId: string): ScheduleFormDisplay { + if (!modeId) { + return { label: "Default mode" }; + } + return { label: modeId }; +} + +function buildInitialThinkingDisplay(thinkingOptionId: string): ScheduleFormDisplay | null { + if (!thinkingOptionId) { + return null; + } + return { label: formatThinkingOptionLabel({ id: thinkingOptionId }) }; +} + +function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): string { + if (schedule?.maxRuns == null) { + return ""; + } + return String(schedule.maxRuns); +} + +function resolveInitialSubmitCadence( + snapshot: ScheduleFormSnapshot, + initialCadence: ScheduleCadence, +): ScheduleCadence { + if (snapshot.mode === "edit" && snapshot.schedule) { + return snapshot.schedule.cadence; + } + return initialCadence; +} + +function resolveInitialIsolation(input: { + config: ReturnType; + preferences: FormPreferences | undefined; +}): "local" | "worktree" { + if (input.config) { + return input.config.isolation ?? "local"; + } + return input.preferences?.isolation ?? "local"; +} + +function resolveSelectedProjectOptionId(target: ScheduleProjectTarget | null): string { + return target?.optionId ?? ""; +} + +function buildInitialProviderResolution( + request: ScheduleProviderSnapshotRequest | null, +): Record { + if (!request) { + return {}; + } + return makeProviderResolutionRecord(request.serverId); +} + +function resolveCanUseWorktreeIsolation(input: { + state: Pick; + hosts: readonly ScheduleFormHost[]; + targets: readonly ScheduleProjectTarget[]; +}): boolean { + const target = resolveProjectTarget({ + targets: input.targets, + serverId: input.state.selectedServerId, + cwd: input.state.workingDir, + }); + const host = input.hosts.find((entry) => entry.serverId === input.state.selectedServerId); + return Boolean(target?.isGit && host?.supportsWorkspaceMultiplicity); +} + +function selectedHostSupportsWorkspaceMultiplicity(input: { + hosts: readonly ScheduleFormHost[]; + selectedServerId: string | null; +}): boolean { + return ( + input.hosts.find((entry) => entry.serverId === input.selectedServerId) + ?.supportsWorkspaceMultiplicity === true + ); +} + +function resolveEffectiveIsolation(input: { + isolation: "local" | "worktree"; + canUseWorktreeIsolation: boolean; + selectedServerId: string | null; + providerResolutionByServerId: Record; +}): "local" | "worktree" { + if (input.isolation !== "worktree") { + return "local"; + } + if (input.canUseWorktreeIsolation) { + return "worktree"; + } + if ( + !input.selectedServerId || + input.providerResolutionByServerId[input.selectedServerId] !== "complete" + ) { + return "worktree"; + } + return "local"; +} + +function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState { + if (state.targetKind === "agent") { + return { + showProjectField: false, + showModelField: false, + showThinkingField: false, + showModeField: false, + showIsolationField: false, + showArchiveOnFinishField: false, + }; + } + + const hasProject = state.workingDir.trim().length > 0; + const hasSelectedProvider = Boolean(state.selectedProvider); + const hasSelectedModel = Boolean(state.selectedProvider && state.selectedModel.trim()); + const showProjectField = state.mode === "edit" || Boolean(state.selectedServerId); + const showModelField = hasProject; + return { + showProjectField, + showModelField, + showThinkingField: + showModelField && hasSelectedModel && state.availableThinkingOptions.length > 0, + showModeField: showModelField && hasSelectedProvider && state.modeOptions.length > 0, + showIsolationField: hasProject && state.canUseWorktreeIsolation, + showArchiveOnFinishField: + hasProject && + selectedHostSupportsWorkspaceMultiplicity({ + hosts: state.hosts, + selectedServerId: state.selectedServerId, + }), + }; +} + +function resolveCanSubmit(state: ScheduleFormState): boolean { + if (state.prompt.trim().length === 0) { + return false; + } + if (state.targetKind === "agent") { + return true; + } + const hasWorkingDir = state.workingDir.trim().length > 0; + const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0; + if (state.mode === "create" && !hasMatchedProject) { + return false; + } + if (!hasWorkingDir) { + return false; + } + return isSelectedModelValidForProviders({ + providers: state.modelSelectorProviders, + selectedProvider: state.selectedProvider, + selectedModel: state.selectedModel, + }); +} + +function updateDerivedState(input: { + state: ScheduleFormState; + hosts: readonly ScheduleFormHost[]; + targets: readonly ScheduleProjectTarget[]; + providerEntries: readonly ProviderSnapshotEntry[]; +}): ScheduleFormState { + const modeOptions = resolveModeOptions(input.providerEntries, input.state.selectedProvider); + const availableThinkingOptions = resolveThinkingOptions( + input.providerEntries, + input.state.selectedProvider, + input.state.selectedModel, + ); + const canUseWorktreeIsolation = resolveCanUseWorktreeIsolation({ + state: input.state, + hosts: input.hosts, + targets: input.targets, + }); + const canSubmitWorkspaceLifecycleOptions = selectedHostSupportsWorkspaceMultiplicity({ + hosts: input.hosts, + selectedServerId: input.state.selectedServerId, + }); + const effectiveIsolation = resolveEffectiveIsolation({ + isolation: input.state.isolation, + canUseWorktreeIsolation, + selectedServerId: input.state.selectedServerId, + providerResolutionByServerId: input.state.providerResolutionByServerId, + }); + const projectTarget = resolveProjectTarget({ + targets: input.targets, + serverId: input.state.selectedServerId, + cwd: input.state.workingDir, + }); + const nextState: ScheduleFormState = { + ...input.state, + hosts: [...input.hosts], + projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId), + selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId, + selectedModelDisplay: resolveModelDisplay({ + entries: input.providerEntries, + provider: input.state.selectedProvider, + modelId: input.state.selectedModel, + }), + selectedModeDisplay: resolveModeDisplay({ modeOptions, modeId: input.state.selectedMode }), + selectedThinkingDisplay: resolveThinkingDisplay({ + options: availableThinkingOptions, + thinkingOptionId: input.state.selectedThinkingOptionId, + }), + modeOptions, + availableThinkingOptions, + canUseWorktreeIsolation, + effectiveIsolation, + submitArchiveOnFinish: canSubmitWorkspaceLifecycleOptions + ? input.state.archiveOnFinish + : undefined, + submitIsolation: canSubmitWorkspaceLifecycleOptions ? effectiveIsolation : undefined, + }; + const disclosure = resolveDisclosure(nextState); + return { ...nextState, disclosure, canSubmit: resolveCanSubmit({ ...nextState, disclosure }) }; +} + +function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { + const selectedServerId = resolveInitialServerId(snapshot); + const config = newAgentConfig(snapshot.schedule); + const targetKind = resolveTargetKind(snapshot); + const workingDir = config?.cwd ?? ""; + const selectedProjectTarget = resolveProjectTarget({ + targets: snapshot.defaults.projectTargets, + serverId: selectedServerId, + cwd: workingDir, + }); + const providerSnapshotRequest = buildProviderSnapshotRequest({ + targetKind, + selectedServerId, + workingDir, + }); + const initialCadence = normalizeScheduleFormCadence( + snapshot.schedule?.cadence ?? DEFAULT_CADENCE, + snapshot.defaults.timezone ?? DEFAULT_TIMEZONE, + ); + const initialModel = config?.model ?? ""; + const initialMode = config?.modeId ?? ""; + const initialThinking = config?.thinkingOptionId ?? ""; + const state: ScheduleFormState = { + mode: snapshot.mode, + targetKind, + name: snapshot.schedule?.name ?? "", + prompt: snapshot.schedule?.prompt ?? "", + maxRuns: formatInitialMaxRuns(snapshot.schedule), + cadence: initialCadence, + submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence), + hosts: [...snapshot.hosts], + projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId), + selectedServerId, + selectedProvider: config?.provider ?? null, + selectedModel: initialModel, + selectedMode: initialMode, + selectedThinkingOptionId: initialThinking, + workingDir, + projectDisplay: buildInitialProjectDisplay({ + config, + targets: snapshot.defaults.projectTargets, + selectedServerId, + }), + selectedProjectOptionId: resolveSelectedProjectOptionId(selectedProjectTarget), + selectedModelDisplay: buildInitialModelDisplay(initialModel), + selectedModeDisplay: buildInitialModeDisplay(initialMode), + selectedThinkingDisplay: buildInitialThinkingDisplay(initialThinking), + modelSelectorProviders: [], + modeOptions: [], + availableThinkingOptions: [], + archiveOnFinish: config?.archiveOnFinish ?? true, + isolation: resolveInitialIsolation({ config, preferences: snapshot.defaults.preferences }), + effectiveIsolation: "local", + submitArchiveOnFinish: undefined, + submitIsolation: undefined, + canUseWorktreeIsolation: false, + providerResolutionByServerId: buildInitialProviderResolution(providerSnapshotRequest), + providerSnapshotRequest, + disclosure: { + showProjectField: false, + showModelField: false, + showThinkingField: false, + showModeField: false, + showIsolationField: false, + showArchiveOnFinishField: false, + }, + canSubmit: false, + submitError: null, + }; + return updateDerivedState({ + state, + hosts: snapshot.hosts, + targets: snapshot.defaults.projectTargets, + providerEntries: [], + }); +} + +function toFormState(state: ScheduleFormState): FormState { + return { + serverId: state.selectedServerId, + provider: state.selectedProvider, + modeId: state.selectedMode, + model: state.selectedModel, + thinkingOptionId: state.selectedThinkingOptionId, + workingDir: state.workingDir, + }; +} + +function applyResolvedFormState(state: ScheduleFormState, form: FormState): ScheduleFormState { + return { + ...state, + selectedServerId: form.serverId, + selectedProvider: form.provider, + selectedMode: form.modeId, + selectedModel: form.model, + selectedThinkingOptionId: form.thinkingOptionId, + workingDir: form.workingDir, + }; +} + +function resolveSnapshotSelection(input: { + state: ScheduleFormState; + snapshot: ScheduleFormSnapshot; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + providerEntries: ProviderSnapshotEntry[]; + userModified: UserModifiedFields; +}): ScheduleFormState { + const providerDefinitions = buildProviderDefinitions(input.providerEntries); + const allowedProviderMap = buildProviderDefinitionMapForStatuses({ + snapshotEntries: input.providerEntries, + providerDefinitions, + statuses: RESOLVABLE_PROVIDER_STATUSES, + }); + const resolved = resolveFormStateFromProviderModels( + input.initialValues, + input.preferences, + buildProviderModelsByProvider(input.providerEntries), + input.userModified, + toFormState(input.state), + allowedProviderMap, + ); + return applyResolvedFormState(input.state, resolved); +} + +function preferencesForSnapshotResolution( + snapshot: ScheduleFormSnapshot, + preferences: FormPreferences | null, +): FormPreferences | null { + return snapshot.mode === "edit" ? null : preferences; +} + +function pickModeForProvider(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider; + currentProvider: AgentProvider | null; + currentMode: string; +}): string { + const currentMode = input.currentMode.trim(); + if (input.currentProvider === input.provider && currentMode) { + return currentMode; + } + const entry = resolveSelectedEntry(input.entries, input.provider); + return entry?.defaultModeId ?? entry?.modes?.[0]?.id ?? ""; +} + +function pickModelForProvider(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider; + modelId: string; +}): string { + const normalizedModelId = input.modelId.trim(); + if (normalizedModelId) { + return normalizedModelId; + } + return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider)); +} + +export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel { + const listeners = new Set<() => void>(); + const initialValues = normalizeInitialValues({ + snapshot, + selectedServerId: resolveInitialServerId(snapshot), + }); + let closed = false; + let hosts = snapshot.hosts; + let projectTargets = snapshot.defaults.projectTargets; + let preferences = snapshot.defaults.preferences ?? null; + let providerEntries: ProviderSnapshotEntry[] = []; + let userModified = { ...INITIAL_USER_MODIFIED, isolation: false }; + const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE; + let state = buildInitialState(snapshot); + + function publish(nextState: ScheduleFormState): void { + if (closed) { + return; + } + state = updateDerivedState({ + state: nextState, + hosts, + targets: projectTargets, + providerEntries, + }); + for (const listener of listeners) { + listener(); + } + } + + function requestProviderSnapshot(serverId: string | null, cwd: string): void { + const trimmedCwd = cwd.trim(); + if (!serverId || !trimmedCwd) { + publish({ + ...state, + providerSnapshotRequest: null, + }); + return; + } + publish({ + ...state, + providerResolutionByServerId: { + ...state.providerResolutionByServerId, + [serverId]: "pending", + }, + providerSnapshotRequest: { serverId, cwd: trimmedCwd }, + }); + } + + function clearProviderSelection(nextState: ScheduleFormState): ScheduleFormState { + providerEntries = []; + return { + ...nextState, + selectedProvider: null, + selectedModel: "", + selectedMode: "", + selectedThinkingOptionId: "", + modelSelectorProviders: [], + modeOptions: [], + availableThinkingOptions: [], + selectedModelDisplay: null, + selectedModeDisplay: { label: "Default mode" }, + selectedThinkingDisplay: null, + providerSnapshotRequest: null, + }; + } + + function resolvePreferences(nextState: ScheduleFormState): ScheduleFormState { + let resolved = nextState; + if ( + snapshot.mode === "create" && + !userModified.isolation && + preferences?.isolation !== undefined + ) { + resolved = { ...resolved, isolation: preferences.isolation }; + } + if (providerEntries.length === 0 || resolved.targetKind !== "new-agent") { + return resolved; + } + return resolveSnapshotSelection({ + state: resolved, + snapshot, + initialValues, + preferences: preferencesForSnapshotResolution(snapshot, preferences), + providerEntries, + userModified, + }); + } + + return { + getState: () => state, + subscribe(listener) { + if (closed) { + return () => {}; + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + applyHosts(nextHosts) { + if (closed || hosts === nextHosts) { + return; + } + hosts = nextHosts; + publish(state); + }, + applyProjectTargets(nextTargets) { + if (closed || projectTargets === nextTargets) { + return; + } + projectTargets = nextTargets; + publish(state); + }, + applyPreferences(nextPreferences) { + const normalizedPreferences = nextPreferences ?? null; + if (closed || preferences === normalizedPreferences) { + return; + } + preferences = normalizedPreferences; + publish(resolvePreferences(state)); + }, + applyProviderSnapshot(serverId, providerSnapshot) { + if (closed || state.selectedServerId !== serverId) { + return; + } + providerEntries = providerSnapshot.entries; + const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId; + const resolved = isPendingResolution + ? resolveSnapshotSelection({ + state, + snapshot, + initialValues, + preferences: preferencesForSnapshotResolution(snapshot, preferences), + providerEntries, + userModified, + }) + : state; + const providerResolutionByServerId: Record = { + ...state.providerResolutionByServerId, + }; + if (isPendingResolution) { + providerResolutionByServerId[serverId] = "complete"; + } + publish({ + ...resolved, + modelSelectorProviders: buildSelectableProviderSelectorProviders(providerEntries), + providerResolutionByServerId, + providerSnapshotRequest: isPendingResolution ? null : state.providerSnapshotRequest, + }); + }, + setHost(serverId) { + if (closed || state.selectedServerId === serverId) { + return; + } + userModified = { + ...userModified, + serverId: true, + workingDir: true, + }; + publish( + clearProviderSelection({ + ...state, + selectedServerId: serverId, + workingDir: "", + projectDisplay: null, + selectedProjectOptionId: "", + providerResolutionByServerId: {}, + }), + ); + }, + setProject(optionId, display) { + if (closed) { + return; + } + const target = findProjectTargetByOptionId(projectTargets, optionId); + if (!target) { + return; + } + const providerScopeChanged = + state.selectedServerId !== target.serverId || state.workingDir !== target.cwd; + if (!providerScopeChanged && state.selectedProjectOptionId === target.optionId) { + return; + } + userModified = { ...userModified, serverId: true, workingDir: true }; + const nextState = { + ...state, + selectedServerId: target.serverId, + workingDir: target.cwd, + projectDisplay: display, + selectedProjectOptionId: target.optionId, + }; + publish(providerScopeChanged ? clearProviderSelection(nextState) : nextState); + if (!providerScopeChanged) { + return; + } + requestProviderSnapshot(target.serverId, target.cwd); + }, + setModel(provider, modelId) { + if (closed) { + return; + } + const selectedModel = pickModelForProvider({ entries: providerEntries, provider, modelId }); + const availableModels = resolveAvailableModels(providerEntries, provider); + const selectedThinkingOptionId = resolveThinkingOptionId({ + availableModels, + modelId: selectedModel, + requestedThinkingOptionId: "", + }); + userModified = { ...userModified, provider: true, model: true }; + publish({ + ...state, + selectedProvider: provider, + selectedModel, + selectedMode: pickModeForProvider({ + entries: providerEntries, + provider, + currentProvider: state.selectedProvider, + currentMode: state.selectedMode, + }), + selectedThinkingOptionId, + }); + }, + setThinking(thinkingOptionId) { + if (closed) { + return; + } + userModified = { ...userModified, thinkingOptionId: true }; + publish({ ...state, selectedThinkingOptionId: thinkingOptionId }); + }, + setSessionMode(modeId) { + if (closed) { + return; + } + userModified = { ...userModified, modeId: true }; + publish({ ...state, selectedMode: modeId }); + }, + setName(value) { + publish({ ...state, name: value }); + }, + setPrompt(value) { + publish({ ...state, prompt: value }); + }, + setMaxRuns(value) { + publish({ ...state, maxRuns: value }); + }, + setCadence(value) { + const cadence = normalizeScheduleFormCadence(value, timezone); + publish({ ...state, cadence, submitCadence: cadence }); + }, + setIsolation(value) { + userModified = { ...userModified, isolation: true }; + publish({ ...state, isolation: value }); + }, + setArchiveOnFinish(value) { + publish({ ...state, archiveOnFinish: value }); + }, + setSubmitError(value) { + publish({ ...state, submitError: value }); + }, + }; +} diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts index ee4c8c2cc..3b7c05a1d 100644 --- a/packages/app/src/schedules/schedule-project-targets.ts +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -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), }); } } diff --git a/packages/app/src/schedules/use-schedule-form-model.test.tsx b/packages/app/src/schedules/use-schedule-form-model.test.tsx new file mode 100644 index 000000000..a9cd3ac4e --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-model.test.tsx @@ -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, + }); + }); +}); diff --git a/packages/app/src/schedules/use-schedule-form-model.ts b/packages/app/src/schedules/use-schedule-form-model.ts new file mode 100644 index 000000000..fe21514cb --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-model.ts @@ -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; +} diff --git a/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts b/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts new file mode 100644 index 000000000..fa7b2d1e0 --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts @@ -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; +} diff --git a/packages/app/src/screens/schedules-screen-state.test.ts b/packages/app/src/screens/schedules-screen-state.test.ts new file mode 100644 index 000000000..d0c8442a2 --- /dev/null +++ b/packages/app/src/screens/schedules-screen-state.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { resolveSchedulesScreenBodyState } from "./schedules-screen-state"; + +describe("resolveSchedulesScreenBodyState", () => { + it("routes failed loading state to the retry UI instead of the spinner", () => { + expect( + resolveSchedulesScreenBodyState({ + loadState: { status: "loading" }, + showLoadError: true, + }), + ).toEqual({ kind: "load-error" }); + }); +}); diff --git a/packages/app/src/screens/schedules-screen-state.ts b/packages/app/src/screens/schedules-screen-state.ts new file mode 100644 index 000000000..43783c5fe --- /dev/null +++ b/packages/app/src/screens/schedules-screen-state.ts @@ -0,0 +1,23 @@ +import type { AggregateLoadState, AggregatedSchedule } from "@/schedules/aggregated-schedules"; + +export type SchedulesScreenBodyState = + | { kind: "loading" } + | { kind: "load-error" } + | { kind: "empty" } + | { kind: "content" }; + +export function resolveSchedulesScreenBodyState(input: { + loadState: AggregateLoadState; + showLoadError: boolean; +}): SchedulesScreenBodyState { + if (input.showLoadError) { + return { kind: "load-error" }; + } + if (input.loadState.status === "connecting" || input.loadState.status === "loading") { + return { kind: "loading" }; + } + if (input.loadState.data.length === 0) { + return { kind: "empty" }; + } + return { kind: "content" }; +} diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index 8a459ada1..76498053a 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -8,9 +8,10 @@ import { } from "react"; import { ScrollView, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; -import { Plus } from "lucide-react-native"; +import { CalendarClock, Plus } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import { MenuHeader } from "@/components/headers/menu-header"; +import { ExternalLink } from "@/components/ui/external-link"; import { HostFilter } from "@/components/hosts/host-filter"; import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker"; import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet"; @@ -22,6 +23,7 @@ import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; import { useSchedules, + type AggregateLoadState, type AggregatedSchedule, type ScheduleHostError, } from "@/hooks/use-schedules"; @@ -31,6 +33,7 @@ import { type ScheduleBucket, type ScheduleTargetAgent, } from "@/schedules/schedule-derivation"; +import { resolveSchedulesScreenBodyState } from "./schedules-screen-state"; import { buildProjectNameByCwd, buildScheduleProjectTargets, @@ -47,6 +50,8 @@ const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: str { value: "ended", label: "Ended", testID: "schedules-filter-ended" }, ]; +const EMPTY_SCHEDULES: AggregatedSchedule[] = []; + export function SchedulesScreen(): ReactElement { const isFocused = useIsFocused(); @@ -58,7 +63,8 @@ export function SchedulesScreen(): ReactElement { } function SchedulesScreenContent(): ReactElement { - const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules(); + const { loadState, hostErrors, isError, refetch } = useSchedules(); + const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES; const { agents } = useAggregatedAgents({ includeArchived: true }); const { projects } = useProjects(); const hosts = useHosts(); @@ -154,7 +160,7 @@ function SchedulesScreenContent(): ReactElement { })); }, [resolvedRows, selectedHost, statusFilter, hosts.length]); - const showLoadError = isError && schedules.length === 0; + const showLoadError = isError && loadState.status !== "loaded"; const showHostFilter = hosts.length > 1; return ( @@ -162,9 +168,8 @@ function SchedulesScreenContent(): ReactElement { 0} - isInitialLoad={isInitialLoad} showLoadError={showLoadError} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} @@ -189,9 +194,8 @@ function SchedulesScreenContent(): ReactElement { function SchedulesScreenBody({ rows, + loadState, hostErrors, - hasSchedules, - isInitialLoad, showLoadError, statusFilter, onStatusFilterChange, @@ -204,9 +208,8 @@ function SchedulesScreenBody({ onEdit, }: { rows: ScheduleRowView[]; + loadState: AggregateLoadState; hostErrors: ScheduleHostError[]; - hasSchedules: boolean; - isInitialLoad: boolean; showLoadError: boolean; statusFilter: ScheduleBucket; onStatusFilterChange: (value: ScheduleBucket) => void; @@ -218,7 +221,9 @@ function SchedulesScreenBody({ onCreate: () => void; onEdit: (schedule: AggregatedSchedule) => void; }): ReactElement { - if (isInitialLoad) { + const bodyState = resolveSchedulesScreenBodyState({ loadState, showLoadError }); + + if (bodyState.kind === "loading") { return ( @@ -226,7 +231,7 @@ function SchedulesScreenBody({ ); } - if (showLoadError) { + if (bodyState.kind === "load-error") { return ( Unable to load schedules @@ -237,19 +242,27 @@ function SchedulesScreenBody({ ); } - if (!hasSchedules) { + if (bodyState.kind === "empty") { return ( - + {hostErrors.length > 0 ? : null} - No schedules yet - + ); } - const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules"; + let schedulesContent: ReactElement; + if (rows.length > 0) { + schedulesContent = ; + } else if (statusFilter === "ended") { + schedulesContent = ; + } else { + schedulesContent = ( + + + + ); + } return ( @@ -271,7 +284,13 @@ function SchedulesScreenBody({ testID="schedules-status-filter" /> - @@ -283,18 +302,45 @@ function SchedulesScreenBody({ testID="schedules-list" > {hostErrors.length > 0 ? : null} - {rows.length > 0 ? ( - - ) : ( - - {emptyFilterText} - - )} + {schedulesContent} ); } +function SchedulesEmptyState({ + onCreate, + testID, +}: { + onCreate: () => void; + testID?: string; +}): ReactElement { + return ( + + + + No active schedules + Schedules run agents on a cadence. + + + + + ); +} + +function SchedulesEndedEmptyState(): ReactElement { + return ( + + + + No ended schedules + + + ); +} + function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement { return ( @@ -345,6 +391,7 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, scrollContent: { + flexGrow: 1, gap: theme.spacing[3], paddingTop: theme.spacing[4], paddingBottom: theme.spacing[6], @@ -364,13 +411,36 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, filterEmpty: { + flexGrow: 1, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, paddingVertical: theme.spacing[6], alignItems: "center", + justifyContent: "center", }, - filterEmptyText: { + emptyState: { + alignItems: "center", + gap: theme.spacing[4], + maxWidth: 420, + width: "100%", + }, + endedEmptyState: { + alignItems: "center", + gap: theme.spacing[3], + }, + emptyTextStack: { + alignItems: "center", + gap: theme.spacing[2], + }, + emptyTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + emptyDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, + textAlign: "center", }, message: { color: theme.colors.foregroundMuted, @@ -382,4 +452,8 @@ const styles = StyleSheet.create((theme) => ({ spinner: { color: theme.colors.foregroundMuted, }, + emptyIcon: { + color: theme.colors.foregroundMuted, + width: theme.iconSize.lg, + }, })); diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx index 540615112..f383c50da 100644 --- a/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx +++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx @@ -6,15 +6,19 @@ import { TerminalProfileEditModal, type ProfileDraft } from "./terminal-profile- const { theme } = vi.hoisted(() => ({ theme: { - spacing: { 2: 8, 3: 12, 4: 16 }, + spacing: { 2: 8, 3: 12, 4: 16, 6: 24 }, fontSize: { sm: 13, base: 15, xs: 11 }, fontWeight: { medium: 500 }, - borderRadius: { lg: 8 }, + borderRadius: { md: 6, lg: 8, xl: 12 }, + borderWidth: { 1: 1 }, + opacity: { 50: 0.5 }, colors: { surface2: "#222", foreground: "#fff", foregroundMuted: "#aaa", border: "#555", + accent: "#0a84ff", + borderAccent: "#555", palette: { red: { 300: "#f87171" } }, }, }, diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx index e3939fc62..7155e461e 100644 --- a/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx +++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; -import { FormField, FormTextInput } from "@/components/ui/form-field"; +import { Field, FormTextInput } from "@/components/ui/form-field"; export interface ProfileDraft { name: string; @@ -139,7 +139,7 @@ export function TerminalProfileEditModal({ desktopMaxWidth={480} > - - + - - + - - + {submitError ? ( diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index 676b74fd0..8bb916205 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useTranslation } from "react-i18next"; +import { useReplicaQuery } from "@/data/query"; +import { workspaceTerminalsPushRoute } from "@/data/push-router"; import { buildTerminalsQueryKey, canCreateWorkspaceTerminal, @@ -11,7 +13,6 @@ import { collectStandaloneTerminalIds, reconcilePendingScriptTerminals, removeTerminalFromPayload, - TERMINALS_QUERY_STALE_TIME, type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "@/screens/workspace/terminals/state"; @@ -77,19 +78,29 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { buildTerminalsQueryKey(normalizedServerId, workspaceDirectory, normalizedWorkspaceId || null), [normalizedServerId, normalizedWorkspaceId, workspaceDirectory], ); + const paneWorkspaceId = normalizedWorkspaceId || undefined; - const query = useQuery({ + const query = useReplicaQuery({ queryKey, enabled: canCreateNow, + pushEvent: "terminals_changed", + meta: workspaceTerminalsPushRoute({ + enabled: canCreateNow, + serverId: normalizedServerId, + cwd: workspaceDirectory ?? "", + ...(paneWorkspaceId ? { workspaceId: paneWorkspaceId } : {}), + }), queryFn: async () => { if (!client || !workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return await client.listTerminals(workspaceDirectory, undefined, { - workspaceId: normalizedWorkspaceId || undefined, - }); + if (paneWorkspaceId) { + return await client.listTerminals(workspaceDirectory, undefined, { + workspaceId: paneWorkspaceId, + }); + } + return await client.listTerminals(workspaceDirectory, undefined, {}); }, - staleTime: TERMINALS_QUERY_STALE_TIME, }); const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]); const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]); @@ -178,54 +189,6 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { }, }); - useEffect(() => { - if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) { - return; - } - - const paneWorkspaceId = normalizedWorkspaceId || undefined; - - const unsubscribeChanged = client.on("terminals_changed", (message) => { - // The terminal subscription is keyed by cwd at the protocol level, so this - // gate only routes the event to the pane watching that cwd; it is not an - // ownership decision. - if (message.payload.cwd !== workspaceDirectory) { - return; - } - - // Two workspaces can share a cwd, so the push can carry terminals from a - // sibling workspace. A terminal belongs to a workspace only by its - // workspaceId, so keep only the ones whose workspaceId matches this pane. - const matchingTerminals = message.payload.terminals.filter( - (terminal) => terminal.workspaceId === paneWorkspaceId, - ); - - queryClient.setQueryData(queryKey, (current) => ({ - cwd: message.payload.cwd, - terminals: matchingTerminals, - requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, - })); - }); - - client.subscribeTerminals({ - cwd: workspaceDirectory, - workspaceId: paneWorkspaceId, - }); - - return () => { - unsubscribeChanged(); - client.unsubscribeTerminals({ cwd: workspaceDirectory, workspaceId: paneWorkspaceId }); - }; - }, [ - client, - isConnected, - isRouteFocused, - normalizedWorkspaceId, - queryClient, - queryKey, - workspaceDirectory, - ]); - useEffect(() => { if (!pendingCreateInput) { return; diff --git a/packages/app/src/utils/schedule-format.test.ts b/packages/app/src/utils/schedule-format.test.ts index 8bc4ea6aa..8715aa03c 100644 --- a/packages/app/src/utils/schedule-format.test.ts +++ b/packages/app/src/utils/schedule-format.test.ts @@ -85,6 +85,7 @@ describe("interval formatting", () => { describe("describeCron", () => { it("humanizes common fixed-time cron shapes", () => { + expect(describeCron({ type: "cron", expression: "* * * * *" })).toBe("Every minute"); expect(describeCron({ type: "cron", expression: "0 * * * *" })).toBe("Every hour"); expect(describeCron({ type: "cron", expression: "15 * * * *" })).toBe("Every hour at :15"); expect(describeCron({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC"); diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts index f915927a1..2fa21ce4e 100644 --- a/packages/app/src/utils/schedule-format.ts +++ b/packages/app/src/utils/schedule-format.ts @@ -106,6 +106,10 @@ export function describeCron(cadence: CronCadence): string | null { const isWildcardMonth = month === "*"; const isWildcardDom = dayOfMonth === "*"; + if (minute === "*" && hour === "*" && isWildcardMonth && isWildcardDom && dayOfWeek === "*") { + return "Every minute"; + } + if (!isLiteralMinute || !isWildcardMonth || !isWildcardDom) { return null; } @@ -123,21 +127,23 @@ export function describeCron(cadence: CronCadence): string | null { } const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`; const timezone = cadence.timezone ?? "UTC"; + const dayLabel = describeCronDay(dayOfWeek); + return dayLabel ? `${dayLabel} at ${time} ${timezone}` : null; +} +function describeCronDay(dayOfWeek: string): string | null { if (dayOfWeek === "*") { - return `Daily at ${time} ${timezone}`; + return "Daily"; } if (dayOfWeek === "1-5") { - return `Weekdays at ${time} ${timezone}`; + return "Weekdays"; } if (dayOfWeek === "0,6" || dayOfWeek === "6,0") { - return `Weekends at ${time} ${timezone}`; + return "Weekends"; } if (/^\d$/.test(dayOfWeek)) { const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)]; - if (day) { - return `${day}s at ${time} ${timezone}`; - } + return day ? `${day}s` : null; } return null; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 910fdd170..de15526f0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -132,6 +132,25 @@ function parseSentFrame( .parse(JSON.parse(assertStr(data))).message; } +function respondToScheduleRequest( + mock: ReturnType, + request: Record, +): void { + const responseType = + request.type === "schedule/create" ? "schedule/create/response" : "schedule/update/response"; + + mock.triggerMessage( + wrapSessionMessage({ + type: responseType, + payload: { + requestId: request.requestId, + schedule: null, + error: null, + }, + }), + ); +} + const clients: DaemonClient[] = []; afterEach(async () => { @@ -546,6 +565,112 @@ test("advertises client capabilities in hello", async () => { }); }); +test("sends new-agent run options when creating schedules", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.scheduleCreate({ + requestId: "request-1", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: "/tmp/project", + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }, + }); + + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual({ + type: "schedule/create", + requestId: "request-1", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: "/tmp/project", + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }, + }); + + respondToScheduleRequest(mock, request); + await expect(createPromise).resolves.toEqual({ + requestId: "request-1", + schedule: null, + error: null, + }); +}); + +test("sends new-agent run options when updating schedules", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const updatePromise = client.scheduleUpdate({ + id: "schedule-1", + requestId: "request-1", + newAgentConfig: { + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }); + + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual({ + type: "schedule/update", + requestId: "request-1", + scheduleId: "schedule-1", + newAgentConfig: { + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }); + + respondToScheduleRequest(mock, request); + await expect(updatePromise).resolves.toEqual({ + requestId: "request-1", + schedule: null, + error: null, + }); +}); + test("sends typed browser automation execute responses", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8a4593f5d..4e1784c89 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -681,6 +681,8 @@ export interface CreateScheduleOptions { modeId?: string; model?: string; thinkingOptionId?: string; + archiveOnFinish?: boolean; + isolation?: "local" | "worktree"; title?: string | null; approvalPolicy?: string; sandboxMode?: string; @@ -704,6 +706,9 @@ export interface UpdateScheduleNewAgentConfig { provider?: string; model?: string | null; modeId?: string | null; + thinkingOptionId?: string | null; + archiveOnFinish?: boolean; + isolation?: "local" | "worktree"; cwd?: string; } export interface UpdateScheduleOptions { diff --git a/packages/protocol/src/schedule/rpc-schemas.test.ts b/packages/protocol/src/schedule/rpc-schemas.test.ts index da40ac740..be932823a 100644 --- a/packages/protocol/src/schedule/rpc-schemas.test.ts +++ b/packages/protocol/src/schedule/rpc-schemas.test.ts @@ -1,9 +1,26 @@ import { describe, expect, it } from "vitest"; -import { ScheduleCreateRequestSchema } from "./rpc-schemas.js"; +import { ScheduleCreateRequestSchema, ScheduleUpdateRequestSchema } from "./rpc-schemas.js"; describe("schedule RPC schemas", () => { - it("keeps new-agent workspace stamps out of create requests", () => { - const parsed = ScheduleCreateRequestSchema.parse({ + it("round-trips new-agent run options on create requests", () => { + expect( + ScheduleCreateRequestSchema.parse({ + type: "schedule/create", + requestId: "request-1", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: "/tmp/project", + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }, + }), + ).toEqual({ type: "schedule/create", requestId: "request-1", prompt: "Run the task", @@ -13,14 +30,35 @@ describe("schedule RPC schemas", () => { config: { provider: "claude", cwd: "/tmp/project", - workspaceId: "client-owned-workspace", + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", }, }, }); + }); - expect(parsed.target.type).toBe("new-agent"); - if (parsed.target.type === "new-agent") { - expect(parsed.target.config).not.toHaveProperty("workspaceId"); - } + it("round-trips new-agent run options on update requests", () => { + expect( + ScheduleUpdateRequestSchema.parse({ + type: "schedule/update", + requestId: "request-1", + scheduleId: "schedule-1", + newAgentConfig: { + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }), + ).toEqual({ + type: "schedule/update", + requestId: "request-1", + scheduleId: "schedule-1", + newAgentConfig: { + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }); }); }); diff --git a/packages/protocol/src/schedule/rpc-schemas.ts b/packages/protocol/src/schedule/rpc-schemas.ts index 9a426f2d3..b975ffcec 100644 --- a/packages/protocol/src/schedule/rpc-schemas.ts +++ b/packages/protocol/src/schedule/rpc-schemas.ts @@ -7,9 +7,7 @@ import { ScheduleTargetSchema, } from "./types.js"; -const ScheduleCreateNewAgentConfigSchema = ScheduleTargetSchema.options[1].shape.config.omit({ - workspaceId: true, -}); +const ScheduleCreateNewAgentConfigSchema = ScheduleTargetSchema.options[1].shape.config; const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [ z.object({ @@ -83,6 +81,9 @@ const ScheduleUpdateNewAgentConfigSchema = z.object({ provider: z.string().trim().min(1).optional(), model: z.string().trim().min(1).nullable().optional(), modeId: z.string().trim().min(1).nullable().optional(), + thinkingOptionId: z.string().trim().min(1).nullable().optional(), + archiveOnFinish: z.boolean().optional(), + isolation: z.enum(["local", "worktree"]).optional(), cwd: z.string().trim().min(1).optional(), }); diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index e5cfbd16d..f02ebf52f 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -27,10 +27,11 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [ config: z.object({ provider: AgentProviderSchema, cwd: z.string().trim().min(1), - workspaceId: z.string().optional(), modeId: z.string().trim().min(1).optional(), model: z.string().trim().min(1).optional(), thinkingOptionId: z.string().trim().min(1).optional(), + archiveOnFinish: z.boolean().optional(), + isolation: z.enum(["local", "worktree"]).optional(), title: z.string().trim().min(1).nullable().optional(), approvalPolicy: z.string().trim().min(1).optional(), sandboxMode: z.string().trim().min(1).optional(), @@ -58,6 +59,7 @@ export const ScheduleRunSchema = z.object({ endedAt: z.string().nullable(), status: z.enum(["running", "succeeded", "failed"]), agentId: z.guid().nullable(), + workspaceId: z.string().nullable().optional(), output: z.string().nullable(), error: z.string().nullable(), }); @@ -100,6 +102,9 @@ export interface UpdateScheduleNewAgentConfig { provider?: string; model?: string | null; modeId?: string | null; + thinkingOptionId?: string | null; + archiveOnFinish?: boolean; + isolation?: "local" | "worktree"; cwd?: string; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b12315fb2..0d86b08f7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -124,7 +124,9 @@ import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { + archiveByScope, archivePersistedWorkspaceRecord, + killTerminalsForWorkspace, type ActiveWorkspaceRef, } from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; @@ -996,14 +998,75 @@ export async function createPaseoDaemon( }); await loopService.initialize(); logger.info({ elapsed: elapsed() }, "Loop service initialized"); + const createScheduleLocalWorkspaceExternal = async (input: { + cwd: string; + firstAgentContext: FirstAgentContext; + }) => { + const workspace = await createLocalCheckoutWorkspace( + { cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) }, + { projectRegistry, workspaceRegistry, workspaceGitService }, + ); + workspaceAutoName.scheduleForDirectory({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext: input.firstAgentContext, + }); + await emitWorkspaceUpdatesExternal([workspace.workspaceId]); + return workspace; + }; + const createSchedulePaseoWorktreeExternal = async (input: { + cwd: string; + firstAgentContext: FirstAgentContext; + }) => { + const result = await createPaseoWorktreeForTools({ + cwd: input.cwd, + firstAgentContext: input.firstAgentContext, + }); + await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]); + return result; + }; + const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => { + await archiveByScope( + { + paseoHome: config.paseoHome, + paseoWorktreesBaseRoot: config.worktreesRoot, + github, + workspaceGitService, + agentManager, + agentStorage, + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + killTerminalsForWorkspace: (workspaceIdToKill) => + killTerminalsForWorkspace( + { + terminalManager, + sessionLogger: logger, + }, + workspaceIdToKill, + ), + sessionLogger: logger, + }, + { + scope: { kind: "workspace", workspaceId }, + repoRoot, + paseoWorktreesBaseRoot: config.worktreesRoot, + requestId: "schedule-run-finish", + }, + ); + }; const scheduleService = new ScheduleService({ paseoHome: config.paseoHome, logger, agentManager, agentStorage, createAgent, - ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal, - workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal, + createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal, + archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); agentManager.setAgentArchivedCallback(async (agentId) => { diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts new file mode 100644 index 000000000..250d8ec58 --- /dev/null +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -0,0 +1,362 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { sep } from "node:path"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { + createDaemonTestContext, + type DaemonClient, + type DaemonTestContext, +} from "./test-utils/index.js"; +import type { SessionOutboundMessage } from "./messages.js"; +import { getPaseoWorktreesRoot } from "../utils/worktree.js"; + +type AgentUpdateMessage = Extract; +type WorkspaceUpdateMessage = Extract; +type ScheduleCreateOptions = Parameters[0]; +type ScheduleSummary = NonNullable>["schedule"]>; +type ScheduleWithRuns = NonNullable< + Awaited>["schedule"] +>; + +let ctx: DaemonTestContext; +const tempRoots: string[] = []; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); + for (const tempRoot of tempRoots.splice(0)) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + tempRoots.push(dir); + return dir; +} + +function createGitRepo(): string { + const tempRoot = makeTempDir("schedule-run-worktree-"); + const repoDir = path.join(tempRoot, "repo"); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + +async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise { + const response = await ctx.client.scheduleCreate(options); + if (response.error || !response.schedule) { + throw new Error(response.error ?? "schedule/create returned no schedule"); + } + return response.schedule; +} + +async function runScheduleOnce(scheduleId: string): Promise { + const response = await ctx.client.scheduleRunOnce({ id: scheduleId }); + if (response.error || !response.schedule) { + throw new Error(response.error ?? "schedule/run-once returned no schedule"); + } + return response.schedule; +} + +async function updateSchedule( + options: Parameters[0], +): Promise { + const response = await ctx.client.scheduleUpdate(options); + if (response.error || !response.schedule) { + throw new Error(response.error ?? "schedule/update returned no schedule"); + } + return response.schedule; +} + +function requireCompletedAgentId(schedule: ScheduleWithRuns): string { + const run = schedule.runs[0]; + if (!run || run.status !== "succeeded" || !run.agentId) { + throw new Error( + `Expected one succeeded run with an agent id: ${JSON.stringify(schedule.runs)}`, + ); + } + return run.agentId; +} + +function collectLifecycleUpdates(): { + agentUpdates: AgentUpdateMessage[]; + workspaceUpdates: WorkspaceUpdateMessage[]; + stop: () => void; +} { + const agentUpdates: AgentUpdateMessage[] = []; + const workspaceUpdates: WorkspaceUpdateMessage[] = []; + const stopAgentUpdates = ctx.client.on("agent_update", (message) => { + agentUpdates.push(message); + }); + const stopWorkspaceUpdates = ctx.client.on("workspace_update", (message) => { + workspaceUpdates.push(message); + }); + return { + agentUpdates, + workspaceUpdates, + stop: () => { + stopAgentUpdates(); + stopWorkspaceUpdates(); + }, + }; +} + +async function waitForAgentUpsert(events: AgentUpdateMessage[], agentId: string): Promise { + await expect + .poll( + () => + events.some( + (message) => message.payload.kind === "upsert" && message.payload.agent.id === agentId, + ), + { timeout: 10_000, interval: 100 }, + ) + .toBe(true); +} + +async function waitForWorkspaceUpsert( + events: WorkspaceUpdateMessage[], + workspaceId: string, +): Promise { + await expect + .poll( + () => + events.some( + (message) => + message.payload.kind === "upsert" && message.payload.workspace.id === workspaceId, + ), + { timeout: 10_000, interval: 100 }, + ) + .toBe(true); +} + +async function waitForWorkspaceRemove( + events: WorkspaceUpdateMessage[], + workspaceId: string, +): Promise { + await expect + .poll( + () => + events.some( + (message) => message.payload.kind === "remove" && message.payload.id === workspaceId, + ), + { timeout: 10_000, interval: 100 }, + ) + .toBe(true); +} + +function workspaceWasRemoved(events: WorkspaceUpdateMessage[], workspaceId: string): boolean { + return events.some( + (message) => message.payload.kind === "remove" && message.payload.id === workspaceId, + ); +} + +function requireWorkspaceUpsert( + events: WorkspaceUpdateMessage[], + workspaceId: string, +): Extract["workspace"] { + const message = events.find( + (event) => event.payload.kind === "upsert" && event.payload.workspace.id === workspaceId, + ); + if (!message || message.payload.kind !== "upsert") { + throw new Error(`Expected workspace upsert for ${workspaceId}`); + } + return message.payload.workspace; +} + +async function activeAgentIds(): Promise> { + const agents = await ctx.client.fetchAgents({ scope: "active" }); + return new Set(agents.entries.map((entry) => entry.agent.id)); +} + +async function archivedAgent(agentId: string) { + const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } }); + const entry = agents.entries.find((item) => item.agent.id === agentId); + if (!entry) { + throw new Error(`Expected archived agent list to contain ${agentId}`); + } + return entry.agent; +} + +async function activeAgent(agentId: string) { + const agent = await ctx.client.fetchAgent({ agentId }); + if (!agent) { + throw new Error(`Expected active agent ${agentId}`); + } + return agent.agent; +} + +test("archiveOnFinish=false local scheduled run emits upserts and remains active", async () => { + const cwd = makeTempDir("schedule-run-local-"); + const schedule = await createNewAgentSchedule({ + prompt: "Say done.", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + ...getFullAccessConfig("codex"), + cwd, + archiveOnFinish: false, + isolation: "local", + }, + }, + runOnCreate: false, + }); + await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "schedule-local-workspaces" } }); + const events = collectLifecycleUpdates(); + + const ran = await runScheduleOnce(schedule.id); + const agentId = requireCompletedAgentId(ran); + const agent = await activeAgent(agentId); + const workspaceId = agent.workspaceId; + + expect(workspaceId).toMatch(/^wks_/); + await waitForWorkspaceUpsert(events.workspaceUpdates, workspaceId!); + await waitForAgentUpsert(events.agentUpdates, agentId); + expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false); + expect(await activeAgentIds()).toContain(agentId); + + events.stop(); +}); + +test("archiveOnFinish=true scheduled run emits a workspace remove", async () => { + const cwd = makeTempDir("schedule-run-archive-"); + const schedule = await createNewAgentSchedule({ + prompt: "Say done.", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + ...getFullAccessConfig("codex"), + cwd, + archiveOnFinish: true, + isolation: "local", + }, + }, + runOnCreate: false, + }); + await ctx.client.fetchWorkspaces({ + subscribe: { subscriptionId: "schedule-archive-workspaces" }, + }); + const events = collectLifecycleUpdates(); + + const ran = await runScheduleOnce(schedule.id); + const agentId = requireCompletedAgentId(ran); + const agent = await archivedAgent(agentId); + const workspaceId = agent.workspaceId; + + expect(workspaceId).toMatch(/^wks_/); + await waitForWorkspaceRemove(events.workspaceUpdates, workspaceId!); + + events.stop(); +}); + +test("worktree isolation creates a run worktree and archiveOnFinish removes it", async () => { + const repoDir = createGitRepo(); + const expectedRoot = await getPaseoWorktreesRoot( + repoDir, + realpathSync(ctx.daemon.paseoHome), + ctx.daemon.config.worktreesRoot, + ); + const schedule = await createNewAgentSchedule({ + prompt: "Say done.", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + archiveOnFinish: true, + isolation: "worktree", + }, + }, + runOnCreate: false, + }); + await ctx.client.fetchWorkspaces({ + subscribe: { subscriptionId: "schedule-worktree-workspaces" }, + }); + const events = collectLifecycleUpdates(); + + const ran = await runScheduleOnce(schedule.id); + const agentId = requireCompletedAgentId(ran); + const agent = await archivedAgent(agentId); + const workspace = requireWorkspaceUpsert(events.workspaceUpdates, agent.workspaceId!); + + expect(workspace.workspaceKind).toBe("worktree"); + expect( + agent.cwd.startsWith(`${expectedRoot}${sep}`), + `agent cwd ${agent.cwd}; expected root ${expectedRoot}`, + ).toBe(true); + expect(agent.cwd).not.toBe(repoDir); + await waitForWorkspaceRemove(events.workspaceUpdates, agent.workspaceId!); + await expect.poll(() => existsSync(agent.cwd), { timeout: 10_000, interval: 100 }).toBe(false); + const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }); + expect(worktreeList).not.toContain(agent.cwd); + + events.stop(); +}); + +test("update_schedule patches thinking, archive behavior, and isolation for the next run", async () => { + const repoDir = createGitRepo(); + const expectedRoot = await getPaseoWorktreesRoot( + repoDir, + realpathSync(ctx.daemon.paseoHome), + ctx.daemon.config.worktreesRoot, + ); + const schedule = await createNewAgentSchedule({ + prompt: "Say done.", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + ...getFullAccessConfig("codex"), + cwd: repoDir, + archiveOnFinish: true, + isolation: "local", + }, + }, + runOnCreate: false, + }); + + await updateSchedule({ + id: schedule.id, + newAgentConfig: { + thinkingOptionId: "think-hard", + archiveOnFinish: false, + isolation: "worktree", + }, + }); + + const ran = await runScheduleOnce(schedule.id); + const agentId = requireCompletedAgentId(ran); + const agent = await activeAgent(agentId); + + expect(agent.thinkingOptionId).toBe("think-hard"); + expect(agent.cwd.startsWith(`${expectedRoot}${sep}`)).toBe(true); + expect(agent.cwd).not.toBe(repoDir); + expect(await activeAgentIds()).toContain(agentId); + expect(existsSync(agent.cwd)).toBe(true); +}); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 4abc112be..e61d28286 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,7 +1,7 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { join, resolve as resolvePath } from "node:path"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentManager } from "../agent/agent-manager.js"; import { AgentStorage } from "../agent/agent-storage.js"; import { createAgentCommand } from "../agent/create-agent/create.js"; @@ -24,8 +24,14 @@ import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js"; +import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js"; import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "../workspace-registry.js"; +import { + type PersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../workspace-registry.js"; +import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js"; import { ScheduleService, ScheduleTargetGoneError, @@ -56,18 +62,93 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick & { agentManager: AgentManager; providerSnapshotManager: Pick; createAgent?: ScheduleServiceOptions["createAgent"]; - ensureWorkspaceForCreate?: ScheduleServiceOptions["ensureWorkspaceForCreate"]; - workspaceRegistry?: ScheduleServiceOptions["workspaceRegistry"]; + createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"]; + createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"]; + archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"]; }; function createScheduleService(options: TestScheduleServiceOptions): ScheduleService { + let workspaceCounter = 0; + const workspaces = new Map(); + const workspaceGitService = createNoopWorkspaceGitService(); + const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async ( + input, + ) => { + const timestamp = new Date().toISOString(); + const workspaceId = `wks_schedule_test_${++workspaceCounter}`; + const workspace: PersistedWorkspaceRecord = { + workspaceId, + projectId: "test-project", + cwd: input.cwd, + kind: "directory", + displayName: "test-project", + title: input.firstAgentContext.prompt, + branch: null, + baseBranch: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + }; + workspaces.set(workspaceId, workspace); + return workspace; + }; + const listActiveWorkspaces = async (): Promise => + Array.from(workspaces.values()) + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => ({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + kind: workspace.kind, + })); + const archiveDefaultWorkspace: ScheduleServiceOptions["archiveWorkspace"] = async ( + workspaceId, + ) => { + workspaceArchiveInProgress = true; + try { + await archiveByScope( + { + github: { invalidate: () => {} } as never, + workspaceGitService, + agentManager: options.agentManager, + agentStorage: options.agentStorage, + findWorkspaceIdForCwd: async (cwd) => + Array.from(workspaces.values()).find((workspace) => workspace.cwd === cwd) + ?.workspaceId ?? null, + listActiveWorkspaces, + archiveWorkspaceRecord: async (id) => { + const workspace = workspaces.get(id); + if (workspace) { + workspaces.set(id, { ...workspace, archivedAt: new Date().toISOString() }); + } + }, + emitWorkspaceUpdatesForWorkspaceIds: async () => {}, + markWorkspaceArchiving: () => {}, + clearWorkspaceArchiving: () => {}, + killTerminalsForWorkspace: async () => {}, + sessionLogger: options.logger, + }, + { + scope: { kind: "workspace", workspaceId }, + repoRoot: null, + requestId: "schedule-service-test", + }, + ); + } finally { + workspaceArchiveInProgress = false; + } + }; return new ScheduleService({ ...options, createAgent: @@ -79,35 +160,34 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer agentStorage: options.agentStorage, logger: options.logger, providerSnapshotManager: options.providerSnapshotManager as ProviderSnapshotManager, - ensureWorkspaceForCreate: options.ensureWorkspaceForCreate, }, input, )), - ensureWorkspaceForCreate: - options.ensureWorkspaceForCreate ?? (async () => "workspace-created-for-schedule"), - workspaceRegistry: options.workspaceRegistry ?? { - async get(workspaceId) { + createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace, + createPaseoWorktreeWorkspace: + options.createPaseoWorktreeWorkspace ?? + (async (input) => { + const workspace = await createDefaultWorkspace(input); return { - workspaceId, - projectId: "test-project", - cwd: options.paseoHome, - kind: "directory", - displayName: "test-project", - title: null, - branch: null, - baseBranch: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, + workspace, + worktree: { branchName: "schedule-test", worktreePath: workspace.cwd }, + intent: { kind: "branch-off", baseBranch: "main", branchName: "schedule-test" }, + repoRoot: workspace.cwd, + created: true, }; - }, - }, + }), + archiveWorkspace: options.archiveWorkspace ?? archiveDefaultWorkspace, }); } -async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ +async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{ workspaceRegistry: FileBackedWorkspaceRegistry; - ensureWorkspaceForCreate: ScheduleServiceOptions["ensureWorkspaceForCreate"]; + createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"]; + createArchiveWorkspace: (input: { + agentManager: AgentManager; + agentStorage: AgentStorage; + logger?: ScheduleServiceOptions["logger"]; + }) => ScheduleServiceOptions["archiveWorkspace"]; }> { const workspaceRegistry = new FileBackedWorkspaceRegistry( join(rootDir, "projects", "workspaces.json"), @@ -122,13 +202,52 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ const workspaceGitService = createNoopWorkspaceGitService(); return { workspaceRegistry, - ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { - const workspace = await createLocalCheckoutWorkspace( - { cwd, title: firstAgentContext?.prompt ?? null }, + createLocalCheckoutWorkspace: async (input) => { + return createLocalCheckoutWorkspace( + { cwd: input.cwd, title: input.firstAgentContext.prompt }, { projectRegistry, workspaceRegistry, workspaceGitService }, ); - return workspace.workspaceId; }, + createArchiveWorkspace: + ({ agentManager, agentStorage, logger = createTestLogger() }) => + async (workspaceId) => { + workspaceArchiveInProgress = true; + try { + await archiveByScope( + { + github: { invalidate: () => {} } as never, + workspaceGitService, + agentManager, + agentStorage, + findWorkspaceIdForCwd: async (cwd) => + resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()), + listActiveWorkspaces: async () => + (await workspaceRegistry.list()) + .filter((workspace) => !workspace.archivedAt) + .map((workspace) => ({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + kind: workspace.kind, + })), + archiveWorkspaceRecord: async (id) => { + await workspaceRegistry.archive(id, new Date().toISOString()); + }, + emitWorkspaceUpdatesForWorkspaceIds: async () => {}, + markWorkspaceArchiving: () => {}, + clearWorkspaceArchiving: () => {}, + killTerminalsForWorkspace: async () => {}, + sessionLogger: logger, + }, + { + scope: { kind: "workspace", workspaceId }, + repoRoot: null, + requestId: "schedule-service-test", + }, + ); + } finally { + workspaceArchiveInProgress = false; + } + }, }; } @@ -373,9 +492,48 @@ describe("ScheduleService", () => { expect(storedAgent?.title).toBe("Audit flaky checkout flow"); }); - test("fired new-agent schedules attach agents to a real workspace registry row", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); + test("new-agent schedule records create no workspace until run time", async () => { + const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } = + await createRegistryBackedScheduleWorkspaceDeps(tempDir); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "server-owned workspace happens at run time", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + runOnCreate: false, + }); + + expect(created.target.config).toMatchObject({ + provider: "claude", + model: "test-model", + cwd: tempDir, + }); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("archiveOnFinish=false local runs create one active workspace per run", async () => { + const { + workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createArchiveWorkspace, + } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ logger: createTestLogger(), clients: createTestAgentClients(), @@ -387,13 +545,16 @@ describe("ScheduleService", () => { agentManager: manager, agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + archiveWorkspace: createArchiveWorkspace({ + agentManager: manager, + agentStorage, + }), now: () => now, }); const created = await service.create({ - prompt: "attach me", + prompt: "repeat in separate workspaces", cadence: { type: "every", everyMs: 60_000 }, target: { type: "new-agent", @@ -401,46 +562,135 @@ describe("ScheduleService", () => { provider: "claude", model: "test-model", cwd: tempDir, + archiveOnFinish: false, + isolation: "local", + }, + }, + maxRuns: 2, + }); + + await service.tick(); + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(2); + const firstAgent = await agentStorage.get(inspected.runs[0]!.agentId!); + const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); + expect(firstAgent?.workspaceId).toMatch(/^wks_/); + expect(secondAgent?.workspaceId).toMatch(/^wks_/); + expect(firstAgent?.workspaceId).not.toBe(secondAgent?.workspaceId); + expect(firstAgent?.archivedAt ?? null).toBeNull(); + expect(secondAgent?.archivedAt ?? null).toBeNull(); + expect(await workspaceRegistry.list()).toEqual([ + expect.objectContaining({ + workspaceId: firstAgent?.workspaceId, + cwd: tempDir, + archivedAt: null, + }), + expect.objectContaining({ + workspaceId: secondAgent?.workspaceId, + cwd: tempDir, + archivedAt: null, + }), + ]); + }); + + test("archiveOnFinish=true archives the run workspace through workspace archive", async () => { + const { + workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createArchiveWorkspace, + } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const archiveAgent = manager.archiveAgent.bind(manager); + manager.archiveAgent = async (agentId) => { + if (!workspaceArchiveInProgress) { + throw new Error("scheduled runs must archive workspaces, not agents directly"); + } + return archiveAgent(agentId); + }; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + archiveWorkspace: createArchiveWorkspace({ + agentManager: manager, + agentStorage, + }), + now: () => now, + }); + + const created = await service.create({ + prompt: "archive the run workspace", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + isolation: "local", }, }, maxRuns: 1, }); - now = new Date("2026-01-01T00:01:00.000Z"); await service.tick(); const inspected = await service.inspect(created.id); + expect(inspected.runs[0]?.status).toBe("succeeded"); const agentId = inspected.runs[0]?.agentId; expect(agentId).toMatch(/^[0-9a-f-]{36}$/); const storedAgent = await agentStorage.get(agentId!); - expect(storedAgent?.workspaceId).toBe(inspected.target.config.workspaceId); - expect(await workspaceRegistry.get(storedAgent!.workspaceId!)).toMatchObject({ - workspaceId: storedAgent?.workspaceId, - cwd: tempDir, - }); + expect(storedAgent?.workspaceId).toMatch(/^wks_/); + expect(storedAgent?.archivedAt).toEqual(expect.any(String)); + expect(await workspaceRegistry.get(storedAgent!.workspaceId!)).toEqual( + expect.objectContaining({ + workspaceId: storedAgent?.workspaceId, + archivedAt: expect.any(String), + }), + ); }); - test("two fires of the same new-agent schedule share one stamped workspace", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); + test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => { + const { + workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createArchiveWorkspace, + } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, }); + const createError = new Error("provider misconfigured"); const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, + createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + archiveWorkspace: createArchiveWorkspace({ + agentManager: manager, + agentStorage, + }), + createAgent: async () => { + throw createError; + }, now: () => now, }); const created = await service.create({ - prompt: "repeat in one workspace", + prompt: "fail before agent exists", cadence: { type: "every", everyMs: 60_000 }, target: { type: "new-agent", @@ -448,665 +698,58 @@ describe("ScheduleService", () => { provider: "claude", model: "test-model", cwd: tempDir, + archiveOnFinish: false, + isolation: "local", }, }, - maxRuns: 2, + runOnCreate: false, }); - await service.tick(); - now = new Date("2026-01-01T00:01:00.000Z"); - await service.tick(); + await expect( + (service as unknown as ScheduleServiceInternals).executeSchedule(created, "run-create-fails"), + ).rejects.toThrow("provider misconfigured"); - const inspected = await service.inspect(created.id); - expect(inspected.runs).toHaveLength(2); - const firstAgent = await agentStorage.get(inspected.runs[0]!.agentId!); - const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); - expect(firstAgent?.workspaceId).toBe(inspected.target.config.workspaceId); - expect(secondAgent?.workspaceId).toBe(inspected.target.config.workspaceId); - expect(firstAgent?.workspaceId).toBe(secondAgent?.workspaceId); - expect(await workspaceRegistry.list()).toHaveLength(1); - }); - - test("new-agent schedules with non-normalized cwd reuse one stamped workspace", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const nonNormalizedCwd = join(tempDir, "child", ".."); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - const created = await service.create({ - prompt: "repeat from relative cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: nonNormalizedCwd, - }, - }, - maxRuns: 2, - }); - - await service.tick(); - now = new Date("2026-01-01T00:01:00.000Z"); - await service.tick(); - - const inspected = await service.inspect(created.id); - const firstAgent = await agentStorage.get(inspected.runs[0]!.agentId!); - const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); - expect(firstAgent?.workspaceId).toBe(secondAgent?.workspaceId); - expect(firstAgent?.workspaceId).toBe(inspected.target.config.workspaceId); - expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ - workspaceId: inspected.target.config.workspaceId, - cwd: resolvePath(nonNormalizedCwd), - }); - expect(await workspaceRegistry.list()).toHaveLength(1); - }); - - test("distinct new-agent schedules with the same target each get their own workspace", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - runner: async () => ({ agentId: null, output: "ok" }), - }); - const target = { - type: "new-agent" as const, - config: { - provider: "claude", - model: "test-model", + expect(await workspaceRegistry.list()).toEqual([ + expect.objectContaining({ cwd: tempDir, - title: "same target", - }, - }; - - const first = await service.create({ - prompt: "first schedule", - cadence: { type: "every", everyMs: 60_000 }, - target, - runOnCreate: false, - }); - const second = await service.create({ - prompt: "second schedule", - cadence: { type: "every", everyMs: 60_000 }, - target, - runOnCreate: false, - }); - - expect(first.target.config.workspaceId).toMatch(/^wks_/); - expect(second.target.config.workspaceId).toMatch(/^wks_/); - expect(first.target.config.workspaceId).not.toBe(second.target.config.workspaceId); - expect(await workspaceRegistry.list()).toHaveLength(2); + archivedAt: expect.any(String), + }), + ]); }); - test("new-agent schedule creates ignore client-provided workspace stamps", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const existingWorkspaceId = await ensureWorkspaceForCreate(tempDir, { - prompt: "client workspace", - }); + test("new-agent cwd existence is checked at run time, not when editing the schedule", async () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); const created = await service.create({ - prompt: "server-owned stamp", + prompt: "missing cwd can be configured", cadence: { type: "every", everyMs: 60_000 }, target: { type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - workspaceId: existingWorkspaceId, - }, + config: { provider: "claude", cwd: join(tempDir, "does-not-exist") }, }, runOnCreate: false, }); - expect(created.target.config.workspaceId).toMatch(/^wks_/); - expect(created.target.config.workspaceId).not.toBe(existingWorkspaceId); - expect(await workspaceRegistry.list()).toHaveLength(2); - }); - - test("legacy new-agent schedules without workspaceId stamp and persist on first fire", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "legacy stamp", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 1, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - await service.tick(); - - const inspected = await service.inspect(legacy.id); - expect(inspected.target.config.workspaceId).toMatch(/^wks_/); - expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ - workspaceId: inspected.target.config.workspaceId, - cwd: tempDir, - }); - const reloaded = await store.get(legacy.id); - expect(reloaded?.target.config.workspaceId).toBe(inspected.target.config.workspaceId); - }); - - test("new-agent schedules restamp a missing workspaceId when the cwd still exists", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "missing workspace stamp", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - workspaceId: "wks_missing", - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 1, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - await service.tick(); - - const inspected = await service.inspect(legacy.id); - expect(inspected.target.config.workspaceId).toMatch(/^wks_/); - expect(inspected.target.config.workspaceId).not.toBe("wks_missing"); - expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ - workspaceId: inspected.target.config.workspaceId, - cwd: tempDir, - archivedAt: null, - }); - expect(inspected.runs[0]?.status).toBe("succeeded"); - }); - - test("new-agent schedules restamp an archived workspaceId when the cwd still exists", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const archivedWorkspaceId = await ensureWorkspaceForCreate(tempDir, { - prompt: "archived workspace stamp", - }); - await workspaceRegistry.archive(archivedWorkspaceId, "2026-01-01T00:00:30.000Z"); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "archived workspace stamp", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - workspaceId: archivedWorkspaceId, - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 1, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - await service.tick(); - - const inspected = await service.inspect(legacy.id); - expect(inspected.target.config.workspaceId).toMatch(/^wks_/); - expect(inspected.target.config.workspaceId).not.toBe(archivedWorkspaceId); - expect(await workspaceRegistry.get(archivedWorkspaceId)).toMatchObject({ - archivedAt: "2026-01-01T00:00:30.000Z", - }); - expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ - workspaceId: inspected.target.config.workspaceId, - cwd: tempDir, - archivedAt: null, - }); - expect(inspected.runs[0]?.status).toBe("succeeded"); - }); - - test("new-agent schedules do not reuse a cached stamp after its workspace is archived", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "cached archived workspace stamp", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 2, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - await service.tick(); - const firstFire = await service.inspect(legacy.id); - const archivedWorkspaceId = firstFire.target.config.workspaceId!; - await workspaceRegistry.archive(archivedWorkspaceId, "2026-01-01T00:00:30.000Z"); - - now = new Date("2026-01-01T00:01:00.000Z"); - await service.tick(); - - const inspected = await service.inspect(legacy.id); - expect(inspected.target.config.workspaceId).toMatch(/^wks_/); - expect(inspected.target.config.workspaceId).not.toBe(archivedWorkspaceId); - expect(await workspaceRegistry.get(archivedWorkspaceId)).toMatchObject({ - archivedAt: "2026-01-01T00:00:30.000Z", - }); - expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ - workspaceId: inspected.target.config.workspaceId, - cwd: tempDir, - archivedAt: null, - }); - expect(inspected.runs).toHaveLength(2); - expect(inspected.runs[1]?.status).toBe("succeeded"); - const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); - expect(secondAgent?.workspaceId).toBe(inspected.target.config.workspaceId); - }); - - test("new-agent schedule cwd updates restamp the workspace for the new cwd", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const nextCwd = join(tempDir, "next-cwd"); - await mkdir(nextCwd, { recursive: true }); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, - }); - - const created = await service.create({ - prompt: "change cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - }, - }, - maxRuns: 1, - }); - const originalWorkspaceId = created.target.config.workspaceId; - const updated = await service.update({ id: created.id, - newAgentConfig: { cwd: nextCwd }, - }); - await service.tick(); - - const inspected = await service.inspect(created.id); - expect(updated.target.config.workspaceId).toMatch(/^wks_/); - expect(updated.target.config.workspaceId).not.toBe(originalWorkspaceId); - expect(inspected.target.config.workspaceId).toBe(updated.target.config.workspaceId); - expect(await workspaceRegistry.get(updated.target.config.workspaceId!)).toMatchObject({ - workspaceId: updated.target.config.workspaceId, - cwd: nextCwd, - archivedAt: null, - }); - const storedAgent = await agentStorage.get(inspected.runs[0]!.agentId!); - expect(storedAgent?.cwd).toBe(nextCwd); - expect(storedAgent?.workspaceId).toBe(updated.target.config.workspaceId); - }); - - test("new-agent schedule equivalent cwd updates reuse the stamped workspace", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tempDir); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate, - workspaceRegistry, - now: () => now, + newAgentConfig: { cwd: join(tempDir, "also-missing") }, }); - const created = await service.create({ - prompt: "same cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - }, - }, - maxRuns: 1, - }); - const originalWorkspaceId = created.target.config.workspaceId; - - const updated = await service.update({ - id: created.id, - newAgentConfig: { cwd: join(tempDir, ".") }, - }); - await service.tick(); - - const inspected = await service.inspect(created.id); - expect(updated.target.config.workspaceId).toBe(originalWorkspaceId); - expect(inspected.target.config.workspaceId).toBe(originalWorkspaceId); - expect(await workspaceRegistry.list()).toHaveLength(1); - expect(await workspaceRegistry.get(originalWorkspaceId!)).toMatchObject({ - workspaceId: originalWorkspaceId, - cwd: tempDir, - archivedAt: null, + expect(updated.target.config).toMatchObject({ + provider: "claude", + cwd: join(tempDir, "also-missing"), }); }); - test("new-agent schedule creation validates cwd before minting a workspace", async () => { - let ensureCalls = 0; - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - return "workspace-created-for-missing-cwd"; - }, - now: () => now, - runner: async () => ({ agentId: null, output: "ok" }), - }); - - await expect( - service.create({ - prompt: "missing cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { provider: "claude", cwd: join(tempDir, "does-not-exist") }, - }, - }), - ).rejects.toThrow(ScheduleTargetGoneError); - expect(ensureCalls).toBe(0); - }); - - test("new-agent schedule creation rejects non-directory cwd before minting a workspace", async () => { - const filePath = join(tempDir, "not-a-directory.txt"); - await writeFile(filePath, "not a directory"); - let ensureCalls = 0; - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - return "workspace-created-for-file-cwd"; - }, - now: () => now, - runner: async () => ({ agentId: null, output: "ok" }), - }); - - await expect( - service.create({ - prompt: "file cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { provider: "claude", cwd: filePath }, - }, - }), - ).rejects.toThrow("is not a directory"); - expect(ensureCalls).toBe(0); - }); - - test("new-agent schedule cwd updates validate cwd before minting a workspace", async () => { - let ensureCalls = 0; - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - return `workspace-${ensureCalls}`; - }, - now: () => now, - runner: async () => ({ agentId: null, output: "ok" }), - }); - - const created = await service.create({ - prompt: "valid cwd", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { provider: "claude", cwd: tempDir }, - }, - }); - - await expect( - service.update({ - id: created.id, - newAgentConfig: { cwd: join(tempDir, "missing-update-cwd") }, - }), - ).rejects.toThrow(ScheduleTargetGoneError); - expect(ensureCalls).toBe(1); - }); - - test("concurrent lazy stamping and update mint one workspace and keep the stored record consistent", async () => { - let releaseFirstStamp: (() => void) | null = null; - const firstStampStarted = new Promise((resolve) => { - releaseFirstStamp = resolve; - }); - let unblockStamp: (() => void) | null = null; - const stampBlocked = new Promise((resolve) => { - unblockStamp = resolve; - }); - let ensureCalls = 0; - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "race stamp", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "test-model", - cwd: tempDir, - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 1, - runs: [], - }); - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - releaseFirstStamp?.(); - await stampBlocked; - return `workspace-${ensureCalls}`; - }, - now: () => now, - }); - - const tickPromise = service.tick(); - await firstStampStarted; - const updatePromise = service.update({ id: legacy.id, prompt: "updated race stamp" }); - unblockStamp?.(); - await Promise.all([tickPromise, updatePromise]); - - const inspected = await service.inspect(legacy.id); - expect(ensureCalls).toBe(1); - expect(inspected.prompt).toBe("updated race stamp"); - expect(inspected.target.config.workspaceId).toBe("workspace-1"); - expect(inspected.runs).toHaveLength(1); - expect(inspected.runs[0]?.status).toBe("succeeded"); - }); - - test("concurrent run finish and update preserve the config stamp and run outcome", async () => { + test("concurrent run finish and update preserve the target config and run outcome", async () => { let finishRun: (() => void) | null = null; const runBlocked = new Promise((resolve) => { finishRun = resolve; @@ -1115,14 +758,6 @@ describe("ScheduleService", () => { const runStarted = new Promise((resolve) => { releaseRun = resolve; }); - let releaseStamp: (() => void) | null = null; - const stampBlocked = new Promise((resolve) => { - releaseStamp = resolve; - }); - let stampStarted: (() => void) | null = null; - const stampStartedSignal = new Promise((resolve) => { - stampStarted = resolve; - }); const store = new ScheduleStore(join(tempDir, "schedules")); const legacy = await store.create({ name: null, @@ -1152,11 +787,6 @@ describe("ScheduleService", () => { agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - stampStarted?.(); - await stampBlocked; - return "workspace-from-update"; - }, now: () => now, runner: async () => { releaseRun?.(); @@ -1174,10 +804,7 @@ describe("ScheduleService", () => { id: legacy.id, newAgentConfig: { modeId: "full-access" }, }); - await stampStartedSignal; finishRun?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - releaseStamp?.(); await Promise.all([tickPromise, updatePromise]); const inspected = await service.inspect(legacy.id); @@ -1185,7 +812,6 @@ describe("ScheduleService", () => { type: "new-agent", config: { modeId: "full-access", - workspaceId: "workspace-from-update", }, }); expect(inspected.runs).toHaveLength(1); @@ -1196,140 +822,6 @@ describe("ScheduleService", () => { }); }); - test("fired new-agent runs use the schedule snapshot captured before concurrent edits", async () => { - let releaseStamp: (() => void) | null = null; - const stampBlocked = new Promise((resolve) => { - releaseStamp = resolve; - }); - let firstStampStarted: (() => void) | null = null; - const firstStampSignal = new Promise((resolve) => { - firstStampStarted = resolve; - }); - let ensureCalls = 0; - const nextCwd = join(tempDir, "next-cwd"); - await mkdir(nextCwd, { recursive: true }); - const createdInputs: Parameters[0][] = []; - const runPrompts: AgentPromptInput[] = []; - const manager = new AgentManager({ - logger: createTestLogger(), - clients: createTestAgentClients(), - registry: agentStorage, - }); - manager.runAgent = async (_agentId, prompt) => { - runPrompts.push(prompt); - return { - sessionId: "scheduled-snapshot-run", - finalText: "old snapshot result", - timeline: [{ type: "assistant_message", text: "old snapshot result" }], - }; - }; - manager.waitForAgentEvent = async () => ({ - status: "idle", - permission: null, - lastMessage: "old snapshot result", - }); - manager.archiveAgent = async () => {}; - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "old prompt", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { - provider: "claude", - model: "old-model", - cwd: tempDir, - }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: 1, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: manager, - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - if (ensureCalls === 1) { - firstStampStarted?.(); - await stampBlocked; - return "workspace-old-snapshot"; - } - return "workspace-new-config"; - }, - createAgent: async (input) => { - createdInputs.push(input); - const snapshot = { - id: "00000000-0000-0000-0000-000000000321", - provider: "claude", - cwd: input.cwd ?? tempDir, - workspaceId: input.workspaceId, - status: "idle", - lifecycle: "idle", - }; - return { - snapshot: snapshot as Awaited< - ReturnType - >["snapshot"], - liveSnapshot: snapshot as Awaited< - ReturnType - >["liveSnapshot"], - background: true, - initialPromptStarted: true, - initialPromptError: null, - }; - }, - now: () => now, - }); - - const tickPromise = service.tick(); - await firstStampSignal; - const updatePromise = service.update({ - id: legacy.id, - prompt: "new prompt", - newAgentConfig: { - cwd: nextCwd, - model: "new-model", - }, - }); - await updatePromise; - releaseStamp?.(); - await tickPromise; - - expect(createdInputs).toHaveLength(1); - expect(createdInputs[0]).toMatchObject({ - cwd: tempDir, - workspaceId: "workspace-old-snapshot", - config: { - model: "old-model", - cwd: tempDir, - }, - }); - expect(createdInputs[0].initialPrompt).toBeUndefined(); - expect(runPrompts).toEqual(["old prompt"]); - const inspected = await service.inspect(legacy.id); - expect(inspected.prompt).toBe("new prompt"); - expect(inspected.target.config).toMatchObject({ - cwd: nextCwd, - model: "new-model", - workspaceId: "workspace-new-config", - }); - expect(inspected.runs[0]).toMatchObject({ - status: "succeeded", - output: "old snapshot result", - }); - }); - test("scheduled new-agent slash prompts run as normal foreground prompts", async () => { const createdInputs: Parameters[0][] = []; const runPrompts: AgentPromptInput[] = []; @@ -1546,6 +1038,80 @@ describe("ScheduleService", () => { }); }); + test("failed new-agent run keeps run error when workspace archive also fails", async () => { + const logger = createTestLogger(); + const warn = vi.fn(); + logger.warn = warn as typeof logger.warn; + logger.child = (() => logger) as typeof logger.child; + const archiveError = new Error("archive exploded"); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + manager.runAgent = async () => { + throw new Error("run exploded"); + }; + const agentId = "00000000-0000-0000-0000-000000000326"; + const service = createScheduleService({ + paseoHome: tempDir, + logger, + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createAgent: async (input) => { + const snapshot = { + id: agentId, + provider: "claude", + cwd: input.cwd ?? tempDir, + workspaceId: input.workspaceId, + status: "idle", + lifecycle: "idle", + }; + return { + snapshot: snapshot as Awaited< + ReturnType + >["snapshot"], + liveSnapshot: snapshot as Awaited< + ReturnType + >["liveSnapshot"], + background: true, + initialPromptStarted: false, + initialPromptError: null, + }; + }, + archiveWorkspace: async () => { + throw archiveError; + }, + now: () => now, + }); + + const created = await service.create({ + prompt: "fail and fail cleanup", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "failed", + error: "run exploded", + agentId, + }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: archiveError, + agentId, + workspaceId: expect.stringMatching(/^wks_/), + scheduleId: created.id, + runId: expect.any(String), + }), + expect.stringContaining("Failed to archive scheduled workspace"), + ); + }); + test("shows scheduled new-agent prompts as normal user turns", async () => { class PromptEchoScheduleSession implements AgentSession { readonly provider = "claude"; @@ -2017,11 +1583,12 @@ describe("ScheduleService", () => { const inspected = await service.inspect(created.id); expect(inspected.runs[0]).toMatchObject({ status: "failed", - agentId: null, + agentId: expect.any(String), error: expect.stringContaining("start turn exploded"), }); const storedAgents = await agentStorage.list(); expect(storedAgents).toHaveLength(1); + expect(inspected.runs[0]?.agentId).toBe(storedAgents[0]?.id); expect(storedAgents[0]).toMatchObject({ archivedAt: expect.any(String), }); @@ -2294,6 +1861,143 @@ describe("ScheduleService", () => { await service2.stop(); }); + test("startup recovery archives an interrupted run workspace with an associated agent", async () => { + const service1 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + const created = await service1.create({ + prompt: "Interrupted after creating an agent", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + runOnCreate: false, + }); + await service1.stop(); + + const interruptedAt = now.toISOString(); + const associatedAgentId = "11111111-1111-4111-8111-111111111111"; + const workspaceId = "wks_interrupted_with_agent"; + const store = new ScheduleStore(join(tempDir, "schedules")); + await store.update(created.id, (schedule) => ({ + ...schedule, + runs: [ + ...schedule.runs, + { + id: "run-interrupted-with-agent", + scheduledFor: interruptedAt, + startedAt: interruptedAt, + endedAt: null, + status: "running", + agentId: associatedAgentId, + workspaceId, + output: null, + error: null, + }, + ], + })); + + const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + now = new Date("2026-01-01T00:10:00.000Z"); + const service2 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { + archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + }, + }); + await service2.start(); + + expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + const inspected = await service2.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "failed", + agentId: associatedAgentId, + error: "Daemon restarted before the scheduled run completed", + }); + await service2.stop(); + }); + + test("startup recovery archives an interrupted run workspace even before agent association", async () => { + const service1 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + const created = await service1.create({ + prompt: "Interrupted before creating an agent", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir, archiveOnFinish: false }, + }, + runOnCreate: false, + }); + await service1.stop(); + + const interruptedAt = now.toISOString(); + const workspaceId = "wks_interrupted_without_agent"; + const store = new ScheduleStore(join(tempDir, "schedules")); + await store.update(created.id, (schedule) => ({ + ...schedule, + runs: [ + ...schedule.runs, + { + id: "run-interrupted-without-agent", + scheduledFor: interruptedAt, + startedAt: interruptedAt, + endedAt: null, + status: "running", + agentId: null, + workspaceId, + output: null, + error: null, + }, + ], + })); + + const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + now = new Date("2026-01-01T00:10:00.000Z"); + const service2 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { + archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + }, + }); + await service2.start(); + + expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + const inspected = await service2.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "failed", + agentId: null, + error: "Daemon restarted before the scheduled run completed", + }); + await service2.stop(); + }); + test("keeps schedules paused when an in-flight run finishes after pause", async () => { let releaseRun: (() => void) | null = null; const runStarted = new Promise((resolve) => { @@ -2577,6 +2281,9 @@ describe("ScheduleService", () => { provider: "codex", model: "gpt-5", modeId: "full-access", + thinkingOptionId: "deep-thought", + archiveOnFinish: false, + isolation: "worktree", cwd: nextCwd, }, }); @@ -2589,9 +2296,11 @@ describe("ScheduleService", () => { config: { provider: "codex", cwd: nextCwd, - workspaceId: "workspace-created-for-schedule", model: "gpt-5", modeId: "full-access", + thinkingOptionId: "deep-thought", + archiveOnFinish: false, + isolation: "worktree", }, }); expect(updated.nextRunAt).toBe("2026-01-01T00:05:30.000Z"); @@ -2599,44 +2308,6 @@ describe("ScheduleService", () => { expect(updated.createdAt).toBe(created.createdAt); }); - test("update stamps legacy new-agent schedules even when target fields are unchanged", async () => { - const store = new ScheduleStore(join(tempDir, "schedules")); - const legacy = await store.create({ - name: null, - prompt: "legacy update", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "new-agent", - config: { provider: "claude", cwd: tempDir }, - }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: null, - runs: [], - }); - const service = createScheduleService({ - paseoHome: tempDir, - logger: createTestLogger(), - agentManager: new AgentManager({ logger: createTestLogger() }), - agentStorage, - providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - now: () => now, - runner: async () => ({ agentId: null, output: "ok" }), - }); - - const updated = await service.update({ id: legacy.id, prompt: "legacy updated" }); - - expect(updated.target.config.workspaceId).toBe("workspace-created-for-schedule"); - expect((await store.get(legacy.id))?.target.config.workspaceId).toBe( - "workspace-created-for-schedule", - ); - }); - test("update switches between every and cron cadences and recomputes nextRunAt", async () => { const service = createScheduleService({ paseoHome: tempDir, @@ -2861,7 +2532,6 @@ describe("ScheduleService", () => { config: { provider: "codex", cwd: tempDir, - workspaceId: "workspace-created-for-schedule", modeId: "full-access", }, }); @@ -3468,28 +3138,13 @@ describe("ScheduleService", () => { expect(await service.list()).toHaveLength(2); }); - test("concurrent createOrReplace first creates share one schedule and workspace", async () => { - let releaseFirstStamp: (() => void) | null = null; - const firstStampStarted = new Promise((resolve) => { - releaseFirstStamp = resolve; - }); - let unblockStamp: (() => void) | null = null; - const stampBlocked = new Promise((resolve) => { - unblockStamp = resolve; - }); - let ensureCalls = 0; + test("concurrent createOrReplace first creates share one schedule", async () => { const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - releaseFirstStamp?.(); - await stampBlocked; - return `workspace-${ensureCalls}`; - }, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -3500,24 +3155,17 @@ describe("ScheduleService", () => { cadence: { type: "every", everyMs: 60_000 }, target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, }); - await firstStampStarted; const secondPromise = service.createOrReplace({ name: "nightly race", prompt: "audit", cadence: { type: "every", everyMs: 60_000 }, target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, }); - unblockStamp?.(); const [first, second] = await Promise.all([firstPromise, secondPromise]); expect(second.id).toBe(first.id); - expect(ensureCalls).toBe(1); expect(await service.list()).toHaveLength(1); - expect(second.target).toMatchObject({ - type: "new-agent", - config: { workspaceId: "workspace-1" }, - }); }); test("createOrReplace dedups new-agent targets regardless of config key order", async () => { diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index bec9b0c5b..bc5d63b9e 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import type { Logger } from "pino"; import type { AgentManager } from "../agent/agent-manager.js"; import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; @@ -9,12 +9,9 @@ import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; -import { - type BoundCreateAgentCommand, - type EnsureWorkspaceForCreate, - formatProviderModel, -} from "../agent/create-agent/create.js"; -import type { WorkspaceRegistry } from "../workspace-registry.js"; +import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js"; +import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; +import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js"; import { ScheduleStore } from "./store.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; import type { @@ -26,9 +23,9 @@ import type { UpdateScheduleInput, UpdateScheduleNewAgentConfig, } from "@getpaseo/protocol/schedule/types"; +import type { FirstAgentContext } from "@getpaseo/protocol/messages"; const SCHEDULE_TICK_INTERVAL_MS = 1000; -const WORKSPACE_STAMP_CACHE_TTL_MS = 5_000; // A run failed because its target no longer exists: the agent was deleted or // archived, or a new-agent cwd was removed. These are permanent, so the schedule @@ -80,9 +77,6 @@ function applyNewAgentConfig( if (!trimmed) { throw new Error("cwd cannot be empty"); } - if (resolve(trimmed) !== resolve(config.cwd)) { - delete config.workspaceId; - } config.cwd = trimmed; } if (patch.model !== undefined) { @@ -101,6 +95,20 @@ function applyNewAgentConfig( delete config.modeId; } } + if (patch.thinkingOptionId !== undefined) { + const trimmed = patch.thinkingOptionId?.trim(); + if (trimmed) { + config.thinkingOptionId = trimmed; + } else { + delete config.thinkingOptionId; + } + } + if (patch.archiveOnFinish !== undefined) { + config.archiveOnFinish = patch.archiveOnFinish; + } + if (patch.isolation !== undefined) { + config.isolation = patch.isolation; + } return { ...target, config }; } @@ -118,6 +126,13 @@ function countCompletedRuns(schedule: StoredSchedule): number { return schedule.runs.filter((run) => run.status !== "running").length; } +function shouldArchiveScheduleRunWorkspace(input: { + agentId: string | null; + archiveOnFinish?: boolean; +}): boolean { + return input.agentId === null || (input.archiveOnFinish ?? true); +} + function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean { if (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= now.getTime()) { return true; @@ -128,56 +143,6 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean { return countCompletedRuns(schedule) >= schedule.maxRuns; } -// Sort object keys recursively so two structurally-equal configs serialize -// identically regardless of key order. Stored configs come back from disk in Zod -// schema order while incoming configs keep their construction order, so a plain -// JSON.stringify comparison would wrongly treat identical targets as different. -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalize); - } - if (value && typeof value === "object") { - const source = value as Record; - return Object.fromEntries( - Object.keys(source) - .sort() - .map((key) => [key, canonicalize(source[key])]), - ); - } - return value; -} - -function scheduleTargetsEqual(a: ScheduleTarget, b: ScheduleTarget): boolean { - if (a.type === "agent" && b.type === "agent") { - return a.agentId === b.agentId; - } - if (a.type === "new-agent" && b.type === "new-agent") { - return JSON.stringify(canonicalize(a.config)) === JSON.stringify(canonicalize(b.config)); - } - return false; -} - -function carryExistingWorkspaceStamp( - existing: ScheduleTarget, - incoming: ScheduleTarget, -): ScheduleTarget { - if ( - existing.type !== "new-agent" || - incoming.type !== "new-agent" || - incoming.config.workspaceId || - !existing.config.workspaceId - ) { - return incoming; - } - return { - ...incoming, - config: { - ...incoming.config, - workspaceId: existing.config.workspaceId, - }, - }; -} - function requireSchedule(schedule: StoredSchedule | null, id: string): StoredSchedule { if (!schedule) { throw new Error(`Schedule not found: ${id}`); @@ -232,7 +197,6 @@ function buildRunOutput(params: { type ScheduleAgentManager = Pick< AgentManager, - | "archiveAgent" | "createAgent" | "getAgent" | "getRegisteredProviderIds" @@ -243,7 +207,10 @@ type ScheduleAgentManager = Pick< | "waitForAgentEvent" >; -type ScheduleWorkspaceRegistry = Pick; +interface ScheduleWorkspaceCreateInput { + cwd: string; + firstAgentContext: FirstAgentContext; +} export interface ScheduleServiceOptions { paseoHome: string; @@ -251,8 +218,13 @@ export interface ScheduleServiceOptions { agentManager: ScheduleAgentManager; agentStorage: AgentStorage; createAgent: BoundCreateAgentCommand; - ensureWorkspaceForCreate: EnsureWorkspaceForCreate; - workspaceRegistry: ScheduleWorkspaceRegistry; + createLocalCheckoutWorkspace: ( + input: ScheduleWorkspaceCreateInput, + ) => Promise; + createPaseoWorktreeWorkspace: ( + input: ScheduleWorkspaceCreateInput, + ) => Promise; + archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; } @@ -263,18 +235,19 @@ export class ScheduleService { private readonly agentManager: ScheduleAgentManager; private readonly agentStorage: AgentStorage; private readonly createAgent: BoundCreateAgentCommand; - private readonly ensureWorkspaceForCreate: EnsureWorkspaceForCreate; - private readonly workspaceRegistry: ScheduleWorkspaceRegistry; + private readonly createLocalCheckoutWorkspace: ( + input: ScheduleWorkspaceCreateInput, + ) => Promise; + private readonly createPaseoWorktreeWorkspace: ( + input: ScheduleWorkspaceCreateInput, + ) => Promise; + private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; private readonly now: () => Date; private readonly runner: ( schedule: StoredSchedule, runId: string, ) => Promise; private readonly runningScheduleIds = new Set(); - private readonly workspaceStampPromises = new Map< - string, - Promise> - >(); private tickTimer: ReturnType | null = null; constructor(options: ScheduleServiceOptions) { @@ -283,8 +256,9 @@ export class ScheduleService { this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; this.createAgent = options.createAgent; - this.ensureWorkspaceForCreate = options.ensureWorkspaceForCreate; - this.workspaceRegistry = options.workspaceRegistry; + this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace; + this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace; + this.archiveWorkspace = options.archiveWorkspace; this.now = options.now ?? (() => new Date()); this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId)); } @@ -314,14 +288,10 @@ export class ScheduleService { async create(input: CreateScheduleInput): Promise { const prompt = normalizePrompt(input.prompt); validateScheduleCadence(input.cadence); - const target = await this.stampNewAgentWorkspace( - stripNewAgentWorkspaceStamp(input.target), - input.prompt, - ); return this.createScheduleRecord(input, { name: trimOptionalName(input.name), prompt, - target, + target: input.target, }); } @@ -364,35 +334,25 @@ export class ScheduleService { const prompt = normalizePrompt(input.prompt); validateScheduleCadence(input.cadence); if (name === null) { - const target = await this.stampNewAgentWorkspace( - stripNewAgentWorkspaceStamp(input.target), - input.prompt, - ); - return this.createScheduleRecord(input, { name, prompt, target }); + return this.createScheduleRecord(input, { name, prompt, target: input.target }); } - const inputTarget = stripNewAgentWorkspaceStamp(input.target); + const inputTarget = input.target; return this.store.upsertByNameAndTarget(name, inputTarget, { create: async () => { - const target = await this.stampNewAgentWorkspace(inputTarget, input.prompt); - return this.buildScheduleRecord(input, { name, prompt, target }); + return this.buildScheduleRecord(input, { name, prompt, target: inputTarget }); }, update: async (current) => { const now = this.now(); const cadence = mergeScheduleCadenceTimezone(current.cadence, input.cadence); const runOnCreate = input.runOnCreate ?? cadence.type === "every"; const nextRunAt = runOnCreate ? now : computeNextRunAt(cadence, now); - const target = await this.stampNewAgentWorkspace( - carryExistingWorkspaceStamp(current.target, inputTarget), - input.prompt, - current.id, - ); return { ...current, name, prompt, cadence, - target, + target: inputTarget, status: "active", pausedAt: null, nextRunAt: nextRunAt.toISOString(), @@ -489,7 +449,7 @@ export class ScheduleService { const patchedTarget = applyNewAgentConfig(updated.target, input.newAgentConfig); updated = { ...updated, - target: await this.stampNewAgentWorkspace(patchedTarget, updated.prompt, updated.id), + target: patchedTarget, }; } @@ -501,12 +461,6 @@ export class ScheduleService { updated = { ...updated, expiresAt: input.expiresAt }; } - if (input.newAgentConfig === undefined) { - updated = { - ...updated, - target: await this.stampNewAgentWorkspace(updated.target, updated.prompt, updated.id), - }; - } return { ...updated, updatedAt: now.toISOString() }; }); return requireSchedule(next, input.id); @@ -623,6 +577,12 @@ export class ScheduleService { } private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise { + const interruptedWorkspaces: Array<{ + workspaceId: string; + repoRoot: string; + agentId: string | null; + runId: string; + }> = []; await this.store.update(scheduleId, (current) => { let updated = { ...current }; let dirty = false; @@ -630,8 +590,24 @@ export class ScheduleService { const runningIndex = updated.runs.findIndex((run) => run.status === "running"); if (runningIndex !== -1) { const runs = [...updated.runs]; + const runningRun = runs[runningIndex]; + if ( + updated.target.type === "new-agent" && + runningRun.workspaceId && + shouldArchiveScheduleRunWorkspace({ + agentId: runningRun.agentId, + archiveOnFinish: updated.target.config.archiveOnFinish, + }) + ) { + interruptedWorkspaces.push({ + workspaceId: runningRun.workspaceId, + repoRoot: updated.target.config.cwd, + agentId: runningRun.agentId, + runId: runningRun.id, + }); + } runs[runningIndex] = { - ...runs[runningIndex], + ...runningRun, status: "failed", endedAt: now.toISOString(), error: "Daemon restarted before the scheduled run completed", @@ -658,6 +634,24 @@ export class ScheduleService { } return current; }); + const interruptedWorkspace = interruptedWorkspaces[0]; + if (!interruptedWorkspace) { + return; + } + try { + await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot); + } catch (error) { + this.logger.warn( + { + err: error, + agentId: interruptedWorkspace.agentId, + workspaceId: interruptedWorkspace.workspaceId, + scheduleId, + runId: interruptedWorkspace.runId, + }, + "Failed to archive interrupted scheduled workspace after daemon restart", + ); + } } // Orphaned agent-target schedules (agent deleted while the daemon was down, or @@ -760,7 +754,7 @@ export class ScheduleService { ...run, status: params.status, endedAt: now.toISOString(), - agentId: params.agentId, + agentId: params.agentId ?? run.agentId, output: params.output, error: params.error, } @@ -806,6 +800,28 @@ export class ScheduleService { requireSchedule(updatedSchedule, params.scheduleId); } + private async recordRunWorkspace(params: { + scheduleId: string; + runId: string; + workspaceId: string; + agentId: string | null; + }): Promise { + const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => ({ + ...schedule, + updatedAt: this.now().toISOString(), + runs: schedule.runs.map((run) => + run.id === params.runId && run.status === "running" + ? { + ...run, + workspaceId: params.workspaceId, + agentId: params.agentId, + } + : run, + ), + })); + requireSchedule(updatedSchedule, params.scheduleId); + } + private async executeSchedule( schedule: StoredSchedule, runId: string, @@ -840,38 +856,53 @@ export class ScheduleService { }; } - const executionSchedule = await this.ensureScheduleWorkspaceStamped(schedule); - const stampedConfig = - executionSchedule.target.type === "new-agent" ? executionSchedule.target.config : null; - if (!stampedConfig) { + const config = schedule.target.type === "new-agent" ? schedule.target.config : null; + if (!config) { throw new Error(`Schedule ${schedule.id} target changed during execution`); } - await this.assertNewAgentCwdDirectory(stampedConfig.cwd); - const created = await this.createAgent({ - kind: "mcp", - provider: formatScheduleProviderModel(stampedConfig), - config: buildScheduleAgentConfig(stampedConfig), - cwd: stampedConfig.cwd, - workspaceId: stampedConfig.workspaceId, - title: resolveScheduleAgentTitle(stampedConfig, executionSchedule.prompt), - labels: { - "paseo.schedule-id": executionSchedule.id, - "paseo.schedule-run": runId, - }, - mode: stampedConfig.modeId, - thinking: stampedConfig.thinkingOptionId, - features: stampedConfig.featureValues, - unattended: true, - promptFailure: "return-error", - background: true, - notifyOnFinish: false, - }); - const agent = created.snapshot; + await this.assertNewAgentCwdDirectory(config.cwd); + let workspace: PersistedWorkspaceRecord | null = null; + let agentId: string | null = null; try { + workspace = await this.createScheduleRunWorkspace(config, schedule.prompt); + await this.recordRunWorkspace({ + scheduleId: schedule.id, + runId, + workspaceId: workspace.workspaceId, + agentId: null, + }); + const runConfig = { ...config, cwd: workspace.cwd }; + const created = await this.createAgent({ + kind: "mcp", + provider: formatScheduleProviderModel(runConfig), + config: buildScheduleAgentConfig(runConfig), + cwd: workspace.cwd, + workspaceId: workspace.workspaceId, + title: resolveScheduleAgentTitle(config, schedule.prompt), + labels: { + "paseo.schedule-id": schedule.id, + "paseo.schedule-run": runId, + }, + mode: config.modeId, + thinking: config.thinkingOptionId, + features: config.featureValues, + unattended: true, + promptFailure: "return-error", + background: true, + notifyOnFinish: false, + }); + const agent = created.snapshot; + agentId = agent.id; + await this.recordRunWorkspace({ + scheduleId: schedule.id, + runId, + workspaceId: workspace.workspaceId, + agentId, + }); if (created.initialPromptError) { throw created.initialPromptError; } - const result = await this.agentManager.runAgent(agent.id, executionSchedule.prompt); + const result = await this.agentManager.runAgent(agent.id, schedule.prompt); const waitResult = await this.agentManager.waitForAgentEvent(agent.id, { waitForActive: true, }); @@ -885,7 +916,6 @@ export class ScheduleService { throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); } const timelineText = curateAgentActivity(result.timeline); - await this.agentManager.archiveAgent(agent.id); return { agentId: agent.id, output: buildRunOutput({ @@ -894,16 +924,40 @@ export class ScheduleService { finalText: result.finalText, }), }; - } catch (error) { - try { - await this.agentManager.archiveAgent(agent.id); - } catch (archiveError) { - this.logger.warn( - { err: archiveError, agentId: agent.id, scheduleId: schedule.id, runId }, - "Failed to archive scheduled agent after failed run", - ); + } finally { + if ( + workspace && + shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish }) + ) { + try { + await this.archiveWorkspace(workspace.workspaceId, config.cwd); + } catch (error) { + this.logger.warn( + { + err: error, + agentId, + workspaceId: workspace.workspaceId, + scheduleId: schedule.id, + runId, + }, + "Failed to archive scheduled workspace after run", + ); + } } - throw error; + } + } + + private async createScheduleRunWorkspace( + config: Extract["config"], + prompt: string, + ): Promise { + const firstAgentContext = { prompt }; + switch (config.isolation ?? "local") { + case "local": + return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext }); + case "worktree": + return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext })) + .workspace; } } @@ -920,120 +974,6 @@ export class ScheduleService { throw error; } } - - private async createWorkspaceStampedTarget( - target: Extract, - prompt: string, - scheduleId?: string, - ): Promise { - await this.assertNewAgentCwdDirectory(target.config.cwd); - const key = scheduleId ? `${scheduleId}:${newAgentWorkspaceStampKey(target)}` : null; - if (key) { - const existing = this.workspaceStampPromises.get(key); - if (existing) { - const stampedTarget = await existing; - const workspaceId = stampedTarget.config.workspaceId; - if ( - workspaceId && - (await this.hasActiveWorkspaceStamp(workspaceId, stampedTarget.config.cwd)) - ) { - return stampedTarget; - } - if (this.workspaceStampPromises.get(key) === existing) { - this.workspaceStampPromises.delete(key); - } - } - } - - const promise = (async () => { - const workspaceId = await this.ensureWorkspaceForCreate(resolve(target.config.cwd), { - prompt, - }); - return { - ...target, - config: { - ...target.config, - workspaceId, - }, - }; - })(); - if (!key) { - return promise; - } - this.workspaceStampPromises.set(key, promise); - try { - const stampedTarget = await promise; - const timer = setTimeout(() => { - if (this.workspaceStampPromises.get(key) === promise) { - this.workspaceStampPromises.delete(key); - } - }, WORKSPACE_STAMP_CACHE_TTL_MS); - timer.unref?.(); - return stampedTarget; - } catch (error) { - if (this.workspaceStampPromises.get(key) === promise) { - this.workspaceStampPromises.delete(key); - } - throw error; - } - } - - private async hasActiveWorkspaceStamp(workspaceId: string, cwd: string): Promise { - const workspace = await this.workspaceRegistry.get(workspaceId); - return Boolean(workspace && !workspace.archivedAt && workspace.cwd === resolve(cwd)); - } - - private async stampNewAgentWorkspace( - target: ScheduleTarget, - prompt: string, - scheduleId?: string, - ): Promise { - if (target.type !== "new-agent" || target.config.workspaceId) { - return target; - } - return this.createWorkspaceStampedTarget(target, prompt, scheduleId); - } - - private async ensureScheduleWorkspaceStamped(schedule: StoredSchedule): Promise { - if (schedule.target.type !== "new-agent") { - return schedule; - } - const stampedWorkspaceId = schedule.target.config.workspaceId; - if ( - stampedWorkspaceId && - (await this.hasActiveWorkspaceStamp(stampedWorkspaceId, schedule.target.config.cwd)) - ) { - return schedule; - } - - await this.assertNewAgentCwdDirectory(schedule.target.config.cwd); - const target = await this.createWorkspaceStampedTarget( - schedule.target, - schedule.prompt, - schedule.id, - ); - const stamped = { - ...schedule, - target, - updatedAt: this.now().toISOString(), - }; - - await this.store.update(schedule.id, (latest) => { - if (latest.target.type !== "new-agent") { - return latest; - } - if (!scheduleTargetsEqual(latest.target, schedule.target)) { - return latest; - } - return { - ...latest, - target, - updatedAt: stamped.updatedAt, - }; - }); - - return stamped; - } } function buildScheduleAgentConfig( @@ -1069,32 +1009,6 @@ function resolveScheduleAgentTitle( ); } -function stripNewAgentWorkspaceStamp(target: ScheduleTarget): ScheduleTarget { - if (target.type !== "new-agent") { - return target; - } - const config = { ...target.config }; - delete config.workspaceId; - return { - ...target, - config, - }; -} - -function newAgentWorkspaceStampKey(target: Extract): string { - const config = target.config; - return JSON.stringify({ - type: target.type, - provider: config.provider, - model: config.model ?? null, - cwd: resolve(config.cwd), - modeId: config.modeId ?? null, - thinkingOptionId: config.thinkingOptionId ?? null, - approvalPolicy: config.approvalPolicy ?? null, - title: config.title ?? null, - }); -} - function formatScheduleProviderModel( config: Extract["config"], ): string { diff --git a/packages/server/src/server/schedule/store.ts b/packages/server/src/server/schedule/store.ts index 51d6c774f..f3583d5f7 100644 --- a/packages/server/src/server/schedule/store.ts +++ b/packages/server/src/server/schedule/store.ts @@ -58,10 +58,9 @@ function targetIdentity(target: ScheduleTarget): unknown { }; } - const { workspaceId: _workspaceId, ...config } = target.config; return { type: target.type, - config, + config: target.config, }; }