Polish schedules list and editing (#1860)

* feat(schedules): polish schedules list and editing

Adds host filtering, status grouping, and target resolution to the
schedules screen. Extracts a shared HostFilter from sessions. Improves
schedule editing with project targets and model hydration. Handles
missing targets server-side instead of retrying forever.

* test(mcp): update schedule mocks for createOrReplace

* fix(schedules): only show host name when multiple hosts exist
This commit is contained in:
Mohamed Boudra
2026-07-02 12:05:35 +02:00
committed by GitHub
parent 2263469342
commit 6513b56571
21 changed files with 1981 additions and 551 deletions
@@ -0,0 +1,118 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, Server } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { HostProfile } from "@/types/host-connection";
import type { Theme } from "@/styles/theme";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
HostStatusDotSlot,
} from "@/components/hosts/host-picker";
const ThemedServer = withUnistyles(Server);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface HostFilterProps {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
}
/**
* The "All hosts / <host>" filter pill shared by the History and Schedules
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
* when only one host exists. Copies the History layout exactly.
*/
export function HostFilter({
hosts,
selectedHost,
onSelectHost,
triggerTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
}));
@@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isWeb } from "@/constants/platform";
import {
describeCron,
everyMsToParts,
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
style={styles.unitControl}
testID="cadence-interval-unit"
/>
</View>
@@ -283,8 +281,6 @@ function CronPresetChip({
);
}
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// Both cadence segmented controls hug their options and stand at the form's
// field height, so the interval row reads as input + toggle rather than a
// full-width track with the controls floating inside it.
// 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",
height: 44,
},
unitControl: {
height: 44,
},
presetRow: {
flexDirection: "row",
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
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.accent,
borderColor: theme.colors.accent,
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.accentForeground,
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: MONOSPACE_FONT,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
color: theme.colors.palette.red[300],
},
}));
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
buildScheduleProjectTargets,
PROJECT_OPTION_PREFIX,
type ScheduleProjectTarget,
} from "@/schedules/schedule-project-targets";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleFormSheetProps {
serverId?: string;
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
schedule?: ScheduleSummary;
}
interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
@@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
};
}
function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
function buildProjectOptionTestId(optionId: string): string {
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
return `schedule-project-option-${targetKey}`;
}
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
const targets: ScheduleProjectTarget[] = [];
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
const options: ComboboxOption[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
const target: ScheduleProjectTarget = {
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
};
targets.push(target);
targetByOptionId.set(target.optionId, target);
options.push({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(cwd)}`,
});
}
}
const targets = buildScheduleProjectTargets(projects);
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
const options: ComboboxOption[] = targets.map((target) => ({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
}));
return { targets, options, targetByOptionId };
}
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
function parseMaxRuns(raw: string): number | null {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function canSubmitScheduleForm(input: {
isAgentTarget: boolean;
isEdit: boolean;
promptTrimmed: string;
cadenceError: string | null;
isSubmitting: boolean;
selectedModelIsValid: boolean;
hasWorkingDir: boolean;
hasSelectedProject: boolean;
}): boolean {
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
return false;
}
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
// non-empty stored cwd; create requires a matched project.
if (input.isAgentTarget) {
return true;
}
if (!input.selectedModelIsValid) {
return false;
}
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
}
export function ScheduleFormSheet({
serverId,
visible,
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
const editConfig = newAgentConfig(schedule);
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
// (service.ts rejects newAgentConfig for them), so the form drops the
// project/model/mode pickers and shows the target agent read-only instead.
const isAgentTarget = isEdit && schedule?.target.type === "agent";
const { projects } = useProjects();
const { agents } = useAggregatedAgents({ includeArchived: true });
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const agentTargetLabel = useMemo(() => {
if (!schedule || schedule.target.type !== "agent") {
return null;
}
const { agentId } = schedule.target;
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
if (!agent) {
return "Agent unavailable";
}
return agent.title?.trim() || "Untitled agent";
}, [agents, schedule, serverId]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerId,
setSelectedServerIdFromUser,
setWorkingDir,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
// Compare against the current server, not the matched target: an unmatched
// stored cwd has no target but still lives on a host, and switching hosts
// must still clear a provider/model that may not exist on the new one.
if (selectedServerId && selectedServerId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
},
[
clearProviderSelectionFromUser,
selectedProjectTarget,
selectedServerId,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
// The sheet stays mounted, and the form reducer's reset-on-close only
// clears user-modified flags — not the picker values — so a create opened
// after an edit would inherit that schedule's server/cwd (including a
// stale ghost path). Clear them so create always starts fresh; provider
// and model re-resolve from preferences.
if (!isEdit) {
setSelectedServerId(null);
setWorkingDir("");
}
}
wasVisibleRef.current = visible;
}, [visible, schedule]);
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
const promptTrimmed = prompt.trim();
const trimmedWorkingDir = workingDir.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit =
promptTrimmed.length > 0 &&
selectedModelIsValid &&
Boolean(selectedProjectTarget) &&
cadenceError === null &&
!isSubmitting;
const canSubmit = canSubmitScheduleForm({
isAgentTarget,
isEdit,
promptTrimmed,
cadenceError,
isSubmitting,
selectedModelIsValid,
hasWorkingDir: trimmedWorkingDir.length > 0,
hasSelectedProject: Boolean(selectedProjectTarget),
});
const handleSubmit = useCallback(async () => {
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
return;
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
return false;
}
setSubmitError(null);
try {
await persistFormPreferences();
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
const maxRunsValue =
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
maxRuns: parseMaxRuns(maxRuns),
});
return true;
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: selectedProjectTarget.cwd,
},
maxRuns: maxRunsValue,
});
} else {
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: selectedProjectTarget.cwd,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
}
onClose();
} catch (error) {
setSubmitError(toErrorMessage(error));
// New-agent target: submit the current working directory. On edit an untouched
// picker leaves this as the stored cwd, so it round-trips unchanged.
const submitNewAgent = useCallback(async (): Promise<boolean> => {
if (!selectedProvider || !trimmedWorkingDir) {
return false;
}
await persistFormPreferences();
const maxRunsValue = parseMaxRuns(maxRuns);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: trimmedWorkingDir,
},
maxRuns: maxRunsValue,
});
return true;
}
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: trimmedWorkingDir,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
return true;
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
onClose,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProjectTarget,
selectedProvider,
selectedThinkingOptionId,
trimmedWorkingDir,
updateSchedule,
]);
const handleSubmit = useCallback(async () => {
if (!promptTrimmed) {
return;
}
setSubmitError(null);
try {
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
if (submitted) {
onClose();
}
} catch (error) {
setSubmitError(toErrorMessage(error));
}
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
onSelect={handleSelectProject}
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{editConfig === null && isEdit ? (
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
) : null}
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
@@ -594,12 +667,15 @@ function ProjectField({
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
/** Stored cwd for an edited schedule whose path matches no known project. */
fallbackCwd: string;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
@@ -629,7 +705,13 @@ function ProjectField({
[open],
);
const displayValue = selectedTarget?.projectName ?? "Select project";
// Honest hydration: a stored cwd that matches no known project shows the
// shortened path itself (not the blank "Select project"), and stays put until
// the user deliberately picks a project.
const storedPath = fallbackCwd.trim();
const displayValue =
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
const isPlaceholder = !selectedTarget && !storedPath;
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
@@ -662,7 +744,7 @@ function ProjectField({
testID="schedule-project-trigger"
>
<Text
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{displayValue}
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.destructive,
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
readonlyField: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
/** Client-derived target line (agent title / project / shortened path). */
targetLabel: string;
/** Provider glyph, resolved from the schedule config or the target agent. */
provider: string | null;
/** Client-derived state — the single source for the badge and next-run copy. */
state: ScheduleDerivedState;
/** Host name, rendered when the list spans more than one host. */
serverName?: string;
/** True when only one host exists and the host name would be redundant. */
singleHost?: boolean;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function resolveProvider(schedule: ScheduleSummary): string | null {
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
function stateBadge(state: ScheduleDerivedState): {
label: string;
variant: "success" | "error" | "muted";
} {
switch (state) {
case "active":
return { label: "Active", variant: "success" };
case "paused":
return { label: "Paused", variant: "muted" };
case "expired":
return { label: "Expired", variant: "muted" };
case "finished":
return { label: "Finished", variant: "muted" };
case "targetGone":
return { label: "Target gone", variant: "error" };
}
}
function resolveModelLabel(schedule: ScheduleSummary): string {
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
return schedule.target.config.model;
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
if (next) {
parts.push(`Next run ${next}`);
}
}
return "Default model";
}
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
return status === "active" ? "success" : "muted";
}
function statusLabel(status: ScheduleSummary["status"]): string {
if (status === "active") {
return "Active";
if (serverName && !singleHost) {
parts.unshift(serverName);
}
if (status === "paused") {
return "Paused";
}
return "Completed";
}
function nextRunLabel(schedule: ScheduleSummary): string {
if (schedule.status === "paused") {
return "Paused";
}
if (schedule.status === "completed") {
return "Completed";
}
return formatNextRun(schedule.nextRunAt) || "—";
return parts.join(" · ");
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
@@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
@@ -126,15 +152,10 @@ export function ScheduleRow({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const provider = resolveProvider(schedule);
const title = resolveScheduleTitle(schedule);
const meta = [
resolveModelLabel(schedule),
formatCadence(schedule.cadence),
nextRunLabel(schedule),
]
.filter(Boolean)
.join(" · ");
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -168,6 +189,9 @@ export function ScheduleRow({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
@@ -175,12 +199,10 @@ export function ScheduleRow({
</View>
<View style={styles.trailing}>
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<StatusBadge label={badge.label} variant={badge.variant} />
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed"}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
@@ -3,44 +3,48 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
schedule: AggregatedSchedule;
targetLabel: string;
provider: string | null;
state: ScheduleDerivedState;
serverName: string;
/** True when only one host exists, so the host name is redundant in rows. */
singleHost: boolean;
}
interface SchedulesTableProps {
serverId: string;
schedules: ScheduleSummary[];
rows: ScheduleRowView[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the header's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: ScheduleSummary) => void;
onEditSchedule: (schedule: AggregatedSchedule) => void;
}
/**
* The schedules list: a single settings-style card of rows in a centered,
* width-constrained reading column, matching the projects list. Owns row-level
* actions (pause/resume/run/delete via the mutations hook + a destructive
* confirm for delete) and delegates editing to the parent.
* The schedules list: a single settings-style card of rows across every
* connected host, in a centered, width-constrained reading column matching the
* projects list. Rows own their host-scoped mutations (pause/resume/run/delete
* via the mutations hook + a destructive confirm) and delegate editing upward.
*/
export function SchedulesTable({
serverId,
schedules,
onEditSchedule,
}: SchedulesTableProps): ReactElement {
const mutations = useScheduleMutations({ serverId });
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{schedules.map((schedule, index) => (
{rows.map((row, index) => (
<SchedulesTableRow
key={schedule.id}
schedule={schedule}
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
@@ -50,28 +54,26 @@ export function SchedulesTable({
}
// ---------------------------------------------------------------------------
// Per-row wrapper owns local in-flight state and binds the table's mutation
// callbacks to this schedule. Local state keeps pending precise to the acting
// row even when several rows are acted on at once (the mutations hook exposes
// only a single global pending flag per action).
// Per-row wrapper owns local in-flight state and binds mutations to this
// schedule's host. Local state keeps pending precise to the acting row even
// when several rows are acted on at once (the mutations hook exposes only a
// single global pending flag per action).
// ---------------------------------------------------------------------------
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
const NO_PENDING: ScheduleRowPending = {};
function SchedulesTableRow({
schedule,
row,
isFirst,
mutations,
onEditSchedule,
}: {
schedule: ScheduleSummary;
row: ScheduleRowView;
isFirst: boolean;
mutations: ScheduleMutations;
onEditSchedule: (schedule: ScheduleSummary) => void;
onEditSchedule: (schedule: AggregatedSchedule) => void;
}): ReactElement {
const { id } = schedule;
const { schedule } = row;
const { id, serverId } = schedule;
const mutations = useScheduleMutations({ serverId });
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
@@ -127,6 +129,11 @@ function SchedulesTableRow({
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}