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
@@ -13,7 +13,10 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
}
}
function updateScheduleSections(
function updateSchedulesData(
queryClient: QueryClient,
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
@@ -72,7 +73,7 @@ function updateScheduleSections(
if (!current) {
return current;
}
return { sections: current.sections.map(updateSection) };
return { ...current, schedules: updateSchedules(current.schedules) };
},
);
}
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.map((schedule) =>
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
),
}
: section,
updateSchedulesData(queryClient, (schedules) =>
schedules.map((schedule) =>
schedule.serverId === serverId && schedule.id === id
? { ...schedule, status, pausedAt }
: schedule,
),
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
updateSchedulesData(queryClient, (schedules) =>
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
);
}
+20 -15
View File
@@ -1,23 +1,30 @@
import { useMemo, useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
type ScheduleHostSection,
} from "@/schedules/aggregated-schedules";
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] 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 {
sections: ScheduleHostSection[];
isLoading: boolean;
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
});
return {
sections: query.data?.sections ?? [],
isLoading: query.isLoading,
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
isError: query.isError,
error: query.error,
refetch: () => {