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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user