Add a Schedules screen to manage recurring agents (#1246)

* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
This commit is contained in:
Mohamed Boudra
2026-06-30 22:58:49 +02:00
committed by GitHub
parent b8b66816ca
commit bf5e3b47e7
36 changed files with 3622 additions and 146 deletions
@@ -80,6 +80,8 @@ interface CombinedModelSelectorProps {
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
@@ -87,6 +89,15 @@ interface CombinedModelSelectorProps {
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
/**
* 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
* stops painting its own hover/pressed background and rounded corners. The
* trigger itself owns the field visuals and reads hovered/pressed to show its
* active state. Without this the trigger stays a content-width toolbar chip
* (the composer's layout).
*/
triggerFill?: boolean;
}
interface SelectorContentProps {
@@ -574,6 +585,7 @@ export function CombinedModelSelector({
isRetryingProvider = false,
disabled = false,
serverId = null,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -693,14 +705,26 @@ export function CombinedModelSelector({
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
],
[disabled, isOpen, renderTrigger],
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
// Fill mode: transparent full-width passthrough. The trigger paints its own
// hover/pressed state from the args, so the wrapper must not double-paint.
if (triggerFill) {
return [
styles.trigger,
styles.customTriggerWrapper,
styles.triggerFill,
disabled && styles.triggerDisabled,
];
}
return [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
];
},
[disabled, isOpen, renderTrigger, triggerFill],
);
const handleBackToAll = useCallback(() => {
@@ -789,12 +813,16 @@ export function CombinedModelSelector({
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
})}
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
hovered: Boolean(hovered),
pressed,
})
}
</Pressable>
) : (
<ComboboxTrigger
@@ -886,6 +914,16 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
height: "auto",
},
// Stretch the wrapper (and, via column + stretch, its single child) to the
// full width of the field, with no background or rounding of its own.
triggerFill: {
alignSelf: "stretch",
flexShrink: 0,
flexDirection: "column",
alignItems: "stretch",
backgroundColor: "transparent",
borderRadius: 0,
},
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,