Scheduled runs each get their own workspace in the sidebar (#1934)

* feat(schedules): per-run workspaces, isolation and archive controls

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

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

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

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

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

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

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

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

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

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

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

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

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
This commit is contained in:
Mohamed Boudra
2026-07-07 22:13:42 +02:00
committed by GitHub
parent d3e1fe48f6
commit d9b65e269f
89 changed files with 9316 additions and 3499 deletions

View File

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

View File

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

View File

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

98
docs/forms.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -162,7 +162,7 @@ export interface HostPickerProps {
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
@@ -183,7 +183,7 @@ export function HostPicker({
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopPlacement = "bottom-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,

View File

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

View File

@@ -1,427 +1,140 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { nextCronCadence } from "@/utils/schedule-cadence-policy";
import {
describeCron,
everyMsToParts,
type IntervalUnit,
partsToEveryMs,
validateCron,
} from "@/utils/schedule-format";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetDisplay,
resolveCronPresetId,
} from "@/schedules/schedule-cadence-options";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { describeCron, validateCron } from "@/utils/schedule-format";
type CadenceMode = ScheduleCadence["type"];
interface CronPreset {
label: string;
expression: string;
}
// 5-field expressions use the cadence timezone. Each one round-trips through
// describeCron() so the chip and the live preview agree.
const CRON_PRESETS: CronPreset[] = [
{ label: "Every hour", expression: "0 * * * *" },
{ label: "Daily 9:00", expression: "0 9 * * *" },
{ label: "Weekdays 9:00", expression: "0 9 * * 1-5" },
{ label: "Mondays 9:00", expression: "0 9 * * 1" },
];
const MODE_OPTIONS = [
{ value: "every" as const, label: "Interval" },
{ value: "cron" as const, label: "Cron" },
];
const UNIT_OPTIONS = [
{ value: "minutes" as const, label: "Minutes" },
{ value: "hours" as const, label: "Hours" },
{ value: "days" as const, label: "Days" },
];
const DEFAULT_INTERVAL_MS = partsToEveryMs(1, "hours");
const DEFAULT_CRON_EXPRESSION = "0 9 * * *";
const UNIT_NOUN: Record<IntervalUnit, string> = {
minutes: "minute",
hours: "hour",
days: "day",
};
function describeInterval(value: number, unit: IntervalUnit): string {
const noun = UNIT_NOUN[unit];
if (value === 1) {
return `Runs every ${noun}`;
}
return `Runs every ${value} ${noun}s`;
}
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error) {
return null;
}
if (!expression) {
return null;
}
const described = describeCron({ type: "cron", expression, timezone });
if (described) {
return described;
}
return expression;
}
function intervalCadenceKey(cadence: Extract<ScheduleCadence, { type: "every" }>): string {
return `${cadence.type}:${cadence.everyMs}`;
}
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
}
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
const mode = value.type;
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
testID: `schedule-cadence-preset-${option.id}`,
}));
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error || !expression) {
return null;
}
return describeCron({ type: "cron", expression, timezone }) ?? expression;
}
function buildCronCadence(expression: string, timezone: string): CronCadence {
return { type: "cron", expression, timezone };
}
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
const rememberedCronTimeZone = useRef(
value.type === "cron" ? (value.timezone ?? "UTC") : deviceTimeZone,
);
const emittedIntervalCadenceKey = useRef<string | null>(null);
const cronTimeZone =
value.type === "cron" ? (value.timezone ?? "UTC") : rememberedCronTimeZone.current;
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
// once from the incoming cadence via lazy state initializers and bump
// resetKey only when we change the content ourselves (mode switch, preset
// chip) — never on every keystroke.
const [intervalValueText, setIntervalValueText] = useState(() =>
String(everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).value),
);
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(
() => everyMsToParts(value.type === "every" ? value.everyMs : DEFAULT_INTERVAL_MS).unit,
);
const [cronText, setCronText] = useState(() =>
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
);
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
const [cronText, setCronText] = useState(() => normalizedValue.expression);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
// Remember the cron expression the user had so toggling Interval -> Cron and
// back does not discard a blanked-out field. Interval mode rebuilds straight
// from the live numeric value + unit, so it needs no equivalent ref.
const lastCronExpression = useRef(
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
const trimmedCron = cronText.trim();
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
const effectiveError = error ?? localCronError;
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
const currentCadence = useMemo<CronCadence>(
() => buildCronCadence(trimmedCron, cronTimeZone),
[cronTimeZone, trimmedCron],
);
const selectedPresetId = resolveCronPresetId(currentCadence);
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
useEffect(() => {
if (value.type === "cron") {
emittedIntervalCadenceKey.current = null;
rememberedCronTimeZone.current = value.timezone ?? "UTC";
lastCronExpression.current = value.expression;
return;
}
const cadenceKey = intervalCadenceKey(value);
if (emittedIntervalCadenceKey.current === cadenceKey) {
emittedIntervalCadenceKey.current = null;
return;
}
rememberedCronTimeZone.current = deviceTimeZone;
lastCronExpression.current = DEFAULT_CRON_EXPRESSION;
setCronText(DEFAULT_CRON_EXPRESSION);
bumpFieldResetKey();
}, [deviceTimeZone, value]);
const parsedIntervalValue = useMemo(() => {
const parsed = Number.parseInt(intervalValueText, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}, [intervalValueText]);
const emitInterval = useCallback(
(rawValue: number, unit: IntervalUnit) => {
if (value.type === "cron") {
rememberedCronTimeZone.current = value.timezone ?? "UTC";
}
const next = { type: "every" as const, everyMs: partsToEveryMs(rawValue, unit) };
emittedIntervalCadenceKey.current = intervalCadenceKey(next);
onChange(next);
},
[onChange, value],
);
const emitCron = useCallback(
(expression: string) => {
lastCronExpression.current = expression;
const next = nextCronCadence(value, expression, rememberedCronTimeZone.current);
rememberedCronTimeZone.current = next.timezone ?? "UTC";
onChange(next);
},
[onChange, value],
);
const handleModeChange = useCallback(
(nextMode: CadenceMode) => {
if (nextMode === mode) {
const handlePresetChange = useCallback(
(presetId: string) => {
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
if (!preset) {
return;
}
if (nextMode === "every") {
emitInterval(parsedIntervalValue, intervalUnit);
} else {
emitCron(cronText.trim() || lastCronExpression.current);
}
setCronText(preset.expression);
bumpFieldResetKey();
onChange(buildCronCadence(preset.expression, cronTimeZone));
},
[mode, parsedIntervalValue, intervalUnit, cronText, emitInterval, emitCron],
);
const handleIntervalValueChange = useCallback(
(text: string) => {
// Keep only digits so the cadence stays a positive integer count.
const digits = text.replace(/[^0-9]/g, "");
setIntervalValueText(digits);
const parsed = Number.parseInt(digits, 10);
emitInterval(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, intervalUnit);
},
[emitInterval, intervalUnit],
);
const handleUnitChange = useCallback(
(unit: IntervalUnit) => {
setIntervalUnit(unit);
emitInterval(parsedIntervalValue, unit);
},
[emitInterval, parsedIntervalValue],
[cronTimeZone, onChange],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
emitCron(text.trim());
onChange(buildCronCadence(text.trim(), cronTimeZone));
},
[emitCron],
[cronTimeZone, onChange],
);
const handlePresetPress = useCallback(
(expression: string) => {
setCronText(expression);
bumpFieldResetKey();
emitCron(expression);
},
[emitCron],
);
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
const trimmedCron = cronText.trim();
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
const cronPreview = getCronPreview(trimmedCron, cronTimeZone, cronError);
let cronFeedback: ReactNode = null;
if (cronError) {
cronFeedback = <Text style={styles.error}>{cronError}</Text>;
} else if (cronPreview) {
cronFeedback = <Text style={styles.preview}>{cronPreview}</Text>;
let feedback: ReactNode = null;
if (effectiveError) {
feedback = <Text style={styles.error}>{effectiveError}</Text>;
} else if (preview) {
feedback = <Text style={styles.preview}>{preview}</Text>;
}
return (
<View style={styles.container}>
<SegmentedControl
size="sm"
value={mode}
onValueChange={handleModeChange}
options={MODE_OPTIONS}
style={styles.modeControl}
testID="cadence-mode"
/>
<Field label="Cadence">
<View style={styles.stack}>
<SelectField
label="Cadence"
value={selectedPresetId === "custom" ? null : selectedPresetId}
selectedDisplay={selectedPresetDisplay}
options={PRESET_OPTIONS}
onChange={handlePresetChange}
placeholder="Select cadence"
emptyText="No cadences found"
searchable={false}
title="Cadence"
size={size}
triggerTestID="schedule-cadence-preset-trigger"
field={false}
/>
{mode === "every" ? (
<View style={styles.section}>
<View style={styles.intervalRow}>
<AdaptiveTextInput
testID="cadence-interval-value"
accessibilityLabel="Interval value"
initialValue={intervalValueText}
resetKey={`cadence-interval-${fieldResetKey}`}
value={intervalValueText}
onChangeText={handleIntervalValueChange}
keyboardType="number-pad"
style={styles.intervalInput}
/>
<SegmentedControl
size="sm"
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
testID="cadence-interval-unit"
/>
</View>
<Text style={styles.preview}>{intervalPreview}</Text>
</View>
) : (
<View style={styles.section}>
<View style={styles.presetRow}>
{CRON_PRESETS.map((preset) => (
<CronPresetChip
key={preset.expression}
label={preset.label}
expression={preset.expression}
isSelected={trimmedCron === preset.expression}
onSelect={handlePresetPress}
/>
))}
</View>
<AdaptiveTextInput
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{cronFeedback}
<Text style={styles.hint}>Times are in {cronTimeZone}</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
);
}
function CronPresetChip({
label,
expression,
isSelected,
onSelect,
}: {
label: string;
expression: string;
isSelected: boolean;
onSelect: (expression: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(expression);
}, [onSelect, expression]);
const chipStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.chip,
isSelected && styles.chipSelected,
!isSelected && (Boolean(hovered) || pressed) && styles.chipHover,
],
[isSelected],
);
const labelStyle = useMemo(
() => [styles.chipLabel, isSelected && styles.chipLabelSelected],
[isSelected],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
style={chipStyle}
>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
<FormTextInput
size={size}
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{feedback}
</View>
</Field>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
stack: {
gap: theme.spacing[3],
},
section: {
gap: theme.spacing[3],
},
intervalRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
intervalInput: {
width: 88,
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
modeControl: {
alignSelf: "flex-start",
},
presetRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
chip: {
minHeight: 32,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hint: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.palette.red[300],

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" });
});
});

View File

@@ -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<AggregatedSchedule>;
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" };
}

View File

@@ -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 {
<MenuHeader title="Schedules" />
<SchedulesScreenBody
rows={visibleRows}
loadState={loadState}
hostErrors={hostErrors}
hasSchedules={schedules.length > 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<AggregatedSchedule>;
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 (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
@@ -226,7 +231,7 @@ function SchedulesScreenBody({
);
}
if (showLoadError) {
if (bodyState.kind === "load-error") {
return (
<View style={styles.centered}>
<Text style={styles.message}>Unable to load schedules</Text>
@@ -237,19 +242,27 @@ function SchedulesScreenBody({
);
}
if (!hasSchedules) {
if (bodyState.kind === "empty") {
return (
<View style={styles.centered} testID="schedules-empty">
<View style={styles.centered}>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<Text style={styles.message}>No schedules yet</Text>
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
<SchedulesEmptyState onCreate={onCreate} testID="schedules-empty" />
</View>
);
}
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
let schedulesContent: ReactElement;
if (rows.length > 0) {
schedulesContent = <SchedulesTable rows={rows} onEditSchedule={onEdit} />;
} else if (statusFilter === "ended") {
schedulesContent = <SchedulesEndedEmptyState />;
} else {
schedulesContent = (
<View style={styles.filterEmpty}>
<SchedulesEmptyState onCreate={onCreate} testID="schedules-empty" />
</View>
);
}
return (
<View style={styles.body}>
@@ -271,7 +284,13 @@ function SchedulesScreenBody({
testID="schedules-status-filter"
/>
</View>
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
<Button
variant="outline"
leftIcon={Plus}
onPress={onCreate}
size="sm"
testID="schedules-new"
>
New schedule
</Button>
</View>
@@ -283,18 +302,45 @@ function SchedulesScreenBody({
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
{schedulesContent}
</ScrollView>
</View>
);
}
function SchedulesEmptyState({
onCreate,
testID,
}: {
onCreate: () => void;
testID?: string;
}): ReactElement {
return (
<View style={styles.emptyState} testID={testID}>
<CalendarClock size={styles.emptyIcon.width} color={styles.emptyIcon.color} />
<View style={styles.emptyTextStack}>
<Text style={styles.emptyTitle}>No active schedules</Text>
<Text style={styles.emptyDescription}>Schedules run agents on a cadence.</Text>
<ExternalLink href="https://paseo.sh/docs/schedules" label="See docs" />
</View>
<Button variant="outline" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
New schedule
</Button>
</View>
);
}
function SchedulesEndedEmptyState(): ReactElement {
return (
<View style={styles.filterEmpty}>
<View style={styles.endedEmptyState}>
<CalendarClock size={styles.emptyIcon.width} color={styles.emptyIcon.color} />
<Text style={styles.emptyTitle}>No ended schedules</Text>
</View>
</View>
);
}
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
return (
<View style={styles.errorsBannerWrap}>
@@ -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,
},
}));

View File

@@ -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" } },
},
},

View File

@@ -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}
>
<View style={styles.body}>
<FormField
<Field
label={t("settings.host.terminalProfiles.nameLabel")}
error={nameError}
testID="terminal-profile-name-field"
@@ -159,9 +159,9 @@ export function TerminalProfileEditModal({
accessibilityLabel={t("settings.host.terminalProfiles.nameLabel")}
testID="terminal-profile-name-input"
/>
</FormField>
</Field>
<FormField
<Field
label={t("settings.host.terminalProfiles.commandLabel")}
error={commandError}
testID="terminal-profile-command-field"
@@ -181,9 +181,9 @@ export function TerminalProfileEditModal({
accessibilityLabel={t("settings.host.terminalProfiles.commandLabel")}
testID="terminal-profile-command-input"
/>
</FormField>
</Field>
<FormField
<Field
label={t("settings.host.terminalProfiles.argsLabel")}
hint={t("settings.host.terminalProfiles.argsHint")}
testID="terminal-profile-args-field"
@@ -203,7 +203,7 @@ export function TerminalProfileEditModal({
accessibilityLabel={t("settings.host.terminalProfiles.argsLabel")}
testID="terminal-profile-args-input"
/>
</FormField>
</Field>
{submitError ? (
<Text style={styles.submitError} testID="terminal-profile-submit-error">

View File

@@ -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<ListTerminalsPayload>(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;

View File

@@ -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");

View File

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

View File

@@ -132,6 +132,25 @@ function parseSentFrame(
.parse(JSON.parse(assertStr(data))).message;
}
function respondToScheduleRequest(
mock: ReturnType<typeof createMockTransport>,
request: Record<string, unknown>,
): 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();

View File

@@ -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 {

View File

@@ -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",
},
});
});
});

View File

@@ -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(),
});

View File

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

View File

@@ -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) => {

View File

@@ -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<SessionOutboundMessage, { type: "agent_update" }>;
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
type ScheduleCreateOptions = Parameters<DaemonClient["scheduleCreate"]>[0];
type ScheduleSummary = NonNullable<Awaited<ReturnType<DaemonClient["scheduleCreate"]>>["schedule"]>;
type ScheduleWithRuns = NonNullable<
Awaited<ReturnType<DaemonClient["scheduleRunOnce"]>>["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<ScheduleSummary> {
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<ScheduleWithRuns> {
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<DaemonClient["scheduleUpdate"]>[0],
): Promise<ScheduleWithRuns> {
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<void> {
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<void> {
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<void> {
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<WorkspaceUpdateMessage["payload"], { kind: "upsert" }>["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<Set<string>> {
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);
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown>;
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<WorkspaceRegistry, "get">;
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<PersistedWorkspaceRecord>;
createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
now?: () => Date;
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
}
@@ -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<PersistedWorkspaceRecord>;
private readonly createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
private readonly now: () => Date;
private readonly runner: (
schedule: StoredSchedule,
runId: string,
) => Promise<ScheduleExecutionResult>;
private readonly runningScheduleIds = new Set<string>();
private readonly workspaceStampPromises = new Map<
string,
Promise<Extract<ScheduleTarget, { type: "new-agent" }>>
>();
private tickTimer: ReturnType<typeof setInterval> | 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<StoredSchedule> {
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<void> {
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<void> {
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<ScheduleTarget, { type: "new-agent" }>["config"],
prompt: string,
): Promise<PersistedWorkspaceRecord> {
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<ScheduleTarget, { type: "new-agent" }>,
prompt: string,
scheduleId?: string,
): Promise<ScheduleTarget> {
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<boolean> {
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<ScheduleTarget> {
if (target.type !== "new-agent" || target.config.workspaceId) {
return target;
}
return this.createWorkspaceStampedTarget(target, prompt, scheduleId);
}
private async ensureScheduleWorkspaceStamped(schedule: StoredSchedule): Promise<StoredSchedule> {
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<ScheduleTarget, { type: "new-agent" }>): 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<ScheduleTarget, { type: "new-agent" }>["config"],
): string {

View File

@@ -58,10 +58,9 @@ function targetIdentity(target: ScheduleTarget): unknown {
};
}
const { workspaceId: _workspaceId, ...config } = target.config;
return {
type: target.type,
config,
config: target.config,
};
}