Files
paseo/packages/app/src/components/schedules/schedule-row.tsx
T
Mohamed BoudraandGitHub ffe76a7e57 Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation

Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics.

* test(cli): expect canonical schedule cadence

* fix(automation): preserve workspace and schedule compatibility

* fix(automation): preserve compatibility edges

* fix(workspaces): align MCP lifecycle resolution

* fix(workspaces): preserve creation intent

* fix(workspaces): preserve branch and schedule identity
2026-07-18 14:04:48 +02:00

418 lines
12 KiB
TypeScript

import { MoreVertical, Pause, Pencil, Play, RotateCw, Trash2 } from "lucide-react-native";
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { getProviderIcon } from "@/components/provider-icons";
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,
scheduleProductName,
} 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
// change (never call useUnistyles in render). See docs/unistyles.md.
const ThemedPencil = withUnistyles(Pencil);
const ThemedPause = withUnistyles(Pause);
const ThemedPlay = withUnistyles(Play);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedKebab = withUnistyles(MoreVertical);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const MENU_ICON_SIZE = 14;
const PROVIDER_ICON_SIZE = 16;
// Pending flags for each action so the parent table can wire a mutation hook
// and the row reflects in-flight state without owning the mutation itself.
export interface ScheduleRowPending {
pause?: boolean;
resume?: boolean;
runNow?: boolean;
delete?: boolean;
}
export interface ScheduleRowActions {
onEdit: () => void;
onPause: () => void;
onResume: () => void;
onRunNow: () => void;
onDelete: () => void;
}
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 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" };
}
}
// 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}`);
}
}
if (serverName && !singleHost) {
parts.unshift(serverName);
}
return parts.join(" · ");
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider);
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
}
/**
* One schedule, rendered as a settings-style card row: provider glyph + title,
* a muted secondary line (model · cadence · next run), a StatusBadge, and the
* kebab menu that owns every row action. Tapping the row opens the editor.
*
* Hover lives on the outer plain View (docs/hover.md): the inner Pressable owns
* press, the nested kebab Pressable never fights it, and the row background
* highlights without reflow.
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: ScheduleRowProps): ReactElement {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const title = resolveScheduleTitle(schedule);
const productName = scheduleProductName(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
settingsStyles.row,
styles.row,
!isFirst && settingsStyles.rowBorder,
isHovered && !isCompact && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isHovered, isCompact],
);
return (
<View
style={styles.rowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
style={rowStyle}
onPress={onEdit}
accessibilityRole="button"
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
testID={`schedule-row-${schedule.id}`}
>
<View style={styles.main}>
<View style={styles.leading}>
<ProviderGlyph provider={provider} />
</View>
<View style={styles.textGroup}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
</View>
</View>
<View style={styles.trailing}>
<StatusBadge label={badge.label} variant={badge.variant} />
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
onDelete={onDelete}
/>
</View>
</Pressable>
</View>
);
}
const editLeading = <ThemedPencil size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const pauseLeading = <ThemedPause size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
function ScheduleExecutionMenuItems({
schedule,
canRun,
pending,
onPause,
onResume,
onRunNow,
}: Pick<ScheduleRowProps, "schedule" | "pending" | "onPause" | "onResume" | "onRunNow"> & {
canRun: boolean;
}): ReactElement | null {
if (schedule.target.type === "agent") {
return null;
}
let cadenceAction: ReactElement;
if (schedule.status === "paused") {
cadenceAction = (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
testID={`schedule-menu-resume-${schedule.id}`}
>
Resume schedule
</DropdownMenuItem>
);
} else {
cadenceAction = (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
testID={`schedule-menu-pause-${schedule.id}`}
>
Pause schedule
</DropdownMenuItem>
);
}
return (
<>
{cadenceAction}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
testID={`schedule-menu-run-${schedule.id}`}
>
Run now
</DropdownMenuItem>
</>
);
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
return (
<ThemedKebab
size={MENU_ICON_SIZE}
uniProps={hovered ? foregroundColorMapping : mutedColorMapping}
/>
);
}
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
const productName = scheduleProductName(schedule);
const productNameLower = productName.toLowerCase();
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityRole={isNative ? "button" : undefined}
accessibilityLabel={`${productName} actions`}
testID={`schedule-kebab-${schedule.id}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
leading={editLeading}
onSelect={onEdit}
testID={`schedule-menu-edit-${schedule.id}`}
>
Edit {productNameLower}
</DropdownMenuItem>
<ScheduleExecutionMenuItems
schedule={schedule}
canRun={canRun}
pending={pending}
onPause={onPause}
onResume={onResume}
onRunNow={onRunNow}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={deleteLeading}
destructive
status={pending?.delete ? "pending" : "idle"}
pendingLabel="Deleting..."
onSelect={onDelete}
testID={`schedule-menu-delete-${schedule.id}`}
>
Delete {productNameLower}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabTriggerStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabTrigger, hovered && styles.kebabTriggerHovered];
}
const styles = StyleSheet.create((theme) => ({
// Static color holder for the dynamic provider icon (compliant idiom).
providerIcon: {
color: theme.colors.foregroundMuted,
},
rowContainer: {
position: "relative",
},
row: {
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
main: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
leading: {
width: PROVIDER_ICON_SIZE,
height: PROVIDER_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
textGroup: {
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
kebabTrigger: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
kebabTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
}));