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, }; }