Merge remote-tracking branch 'origin/main' into feat/desktop-cloud-summary

This commit is contained in:
John Choi
2026-10-20 21:01:15 -07:00
4 changed files with 331 additions and 33 deletions
@@ -1576,6 +1576,31 @@ describe("Code sidecar runtime capabilities", () => {
]);
});
it.each([
"schedule.execution_failed",
"schedule.execution_completed",
])("forwards %s without requiring an attached session", async (event) => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
const payload = {
scheduleId: "schedule-1",
executionId: "execution-1",
errorMessage: "getaddrinfo ENOTFOUND api.cline.bot",
};
handleHubLiveEvent(ctx, { event, payload });
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: { name: event, payload: { environmentId: "local", ...payload } },
},
]);
});
it("forwards Hub settings changes so open desktop views can refresh", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
@@ -937,6 +937,12 @@ export function handleHubLiveEvent(
});
return;
}
// Schedules can fail before creating a session. Relay their lifecycle
// events independently of the live-chat/session projection as well.
if (event.event.startsWith("schedule.")) {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
if (event.event === "settings.changed") {
sendEvent(ctx, event.event, event.payload ?? {});
return;
@@ -0,0 +1,233 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineSchedulesContent } from "./routine-view";
const { invoke, subscribe } = vi.hoisted(() => ({
invoke: vi.fn(),
subscribe: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke, subscribe },
}));
vi.mock("@/lib/provider-model-catalog", () => ({
loadProviderModelCatalog: async () => ({
providerModels: {},
enabledProviderIds: [],
}),
loadProviderModels: async () => [],
}));
vi.mock("@/lib/model-selection", () => ({
readModelSelectionStorageFromWindow: () => ({
lastProvider: "",
lastModelByProvider: {},
}),
}));
const schedule = {
scheduleId: "schedule-1",
name: "Daily PR summary",
cronPattern: "0 9 * * *",
prompt: "Summarize PRs",
mode: "yolo",
maxParallel: 1,
enabled: true,
createdAt: 1,
updatedAt: 1,
};
function overview(status = "pending", errorMessage?: string) {
return {
schedules: [schedule],
activeExecutions: [],
upcomingRuns: [],
lastExecutions: [
{
executionId: "execution-1",
scheduleId: schedule.scheduleId,
status,
errorMessage,
},
],
};
}
let container: HTMLDivElement;
let root: Root;
let handlers: Map<string, () => void>;
let unsubscribers: Array<ReturnType<typeof vi.fn>>;
beforeEach(() => {
vi.useFakeTimers();
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
handlers = new Map();
unsubscribers = [];
invoke.mockReset().mockResolvedValue(overview());
subscribe
.mockReset()
.mockImplementation((event: string, handler: () => void) => {
handlers.set(event, handler);
const unsubscribe = vi.fn(() => handlers.delete(event));
unsubscribers.push(unsubscribe);
return unsubscribe;
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
});
async function mount() {
await act(async () => root.render(<RoutineSchedulesContent />));
await act(async () => vi.advanceTimersByTimeAsync(0));
}
describe("schedule execution updates", () => {
it("shows a failure received after loading Pending, even without a session", async () => {
await mount();
expect(container.textContent).toContain("pending");
const error =
"Unauthorized: Re-authenticate your Cline account.\nRequest ID: example";
invoke.mockResolvedValue(overview("failed", error));
await act(async () => handlers.get("schedule.execution_failed")?.());
expect(container.textContent).toContain("Last error:");
expect(container.textContent).toContain(error);
expect(container.textContent).not.toContain("pending");
// A startup failure has no chat to open, so the history must expose
// the full error directly rather than relying on session navigation.
await act(async () =>
container.querySelector<HTMLElement>('[role="button"]')?.click(),
);
const historyError = Array.from(document.querySelectorAll("span")).find(
(element) => element.textContent === error,
);
expect(historyError).toBeDefined();
expect(historyError?.classList.contains("truncate")).toBe(false);
});
it("fetches fresh data when Refresh is clicked immediately after loading", async () => {
await mount();
invoke.mockResolvedValue(overview("failed", "Cannot connect to API"));
await act(async () =>
container
.querySelector<HTMLButtonElement>(
'button[aria-label="Refresh schedules"]',
)
?.click(),
);
expect(invoke).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("Cannot connect to API");
});
it("polls transitions and refreshes when the window regains focus", async () => {
await mount();
invoke.mockResolvedValue(overview("running"));
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(container.textContent).toContain("running");
invoke.mockResolvedValue(
overview("failed", "getaddrinfo ENOTFOUND api.cline.bot"),
);
await act(async () => window.dispatchEvent(new Event("focus")));
expect(container.textContent).toContain(
"getaddrinfo ENOTFOUND api.cline.bot",
);
});
it("allows consistently slow overview requests to finish between polls", async () => {
invoke.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() => resolve(overview("failed", "Cannot connect to API")),
20_000,
);
}),
);
await mount();
await act(async () => vi.advanceTimersByTimeAsync(15_000));
expect(invoke).toHaveBeenCalledTimes(1);
await act(async () => window.dispatchEvent(new Event("focus")));
expect(invoke).toHaveBeenCalledTimes(1);
await act(async () => vi.advanceTimersByTimeAsync(5_000));
expect(container.textContent).toContain("Cannot connect to API");
await act(async () => vi.advanceTimersByTimeAsync(30_000));
expect(invoke).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("Cannot connect to API");
});
it("shows a slow request's timeout and resumes polling afterward", async () => {
let rejectRequest!: (error: Error) => void;
invoke.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectRequest = reject;
}),
);
await mount();
await act(async () => vi.advanceTimersByTimeAsync(119_000));
expect(invoke).toHaveBeenCalledTimes(1);
await act(async () =>
rejectRequest(new Error("Overview request timed out")),
);
expect(container.textContent).toContain("Overview request timed out");
expect(
container.querySelector<HTMLButtonElement>(
'button[aria-label="Refresh schedules"]',
)?.disabled,
).toBe(false);
invoke.mockResolvedValue(overview("success"));
await act(async () => vi.advanceTimersByTimeAsync(1_000));
expect(invoke).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("success");
expect(container.textContent).not.toContain("Overview request timed out");
});
it("does not let an older response overwrite a completed execution", async () => {
await mount();
let resolveOld!: (response: ReturnType<typeof overview>) => void;
invoke.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve;
}),
);
await act(async () => window.dispatchEvent(new Event("focus")));
invoke.mockResolvedValue(overview("success"));
await act(async () => handlers.get("schedule.execution_completed")?.());
await act(async () => resolveOld(overview("pending")));
expect(container.textContent).toContain("success");
expect(container.textContent).not.toContain("pending");
});
it("removes subscriptions and polling when leaving the page", async () => {
await mount();
await act(async () => root.render(null));
const calls = invoke.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
window.dispatchEvent(new Event("focus"));
});
expect(invoke).toHaveBeenCalledTimes(calls);
for (const unsubscribe of unsubscribers)
expect(unsubscribe).toHaveBeenCalledOnce();
});
});
@@ -144,13 +144,10 @@ interface RoutineOverviewResponse {
lastExecutions: RoutineExecution[];
}
const ROUTINE_OVERVIEW_CACHE_TTL_MS = 30_000;
const ROUTINE_OVERVIEW_POLL_INTERVAL_MS = 15_000;
let routineOverviewCache:
| (RoutineOverviewResponse & {
fetchedAt: number;
})
| null = null;
// Preserve the last snapshot for navigation, but always revalidate on mount.
let routineOverviewCache: RoutineOverviewResponse | null = null;
async function fetchRoutineOverview(): Promise<RoutineOverviewResponse> {
const response = await desktopClient.invoke<RoutineOverviewResponse>(
@@ -517,6 +514,8 @@ export function RoutineSchedulesContent({
);
const [isLoading, setIsLoading] = useState(() => !routineOverviewCache);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const overviewRequestSequence = useRef(0);
const overviewRefreshPending = useRef(false);
// Sets rather than single ids: several rows can have in-flight actions at
// once, and one action finishing must not clear another row's busy state.
const [busyScheduleIds, setBusyScheduleIds] = useState<ReadonlySet<string>>(
@@ -732,28 +731,20 @@ export function RoutineSchedulesContent({
]);
const refreshSchedules = useCallback(
async (options?: { force?: boolean; showLoading?: boolean }) => {
const now = Date.now();
if (
!options?.force &&
routineOverviewCache &&
now - routineOverviewCache.fetchedAt < ROUTINE_OVERVIEW_CACHE_TTL_MS
) {
setSchedules(routineOverviewCache.schedules);
setActiveExecutions(routineOverviewCache.activeExecutions);
setUpcomingRuns(routineOverviewCache.upcomingRuns);
setLastExecutions(routineOverviewCache.lastExecutions);
setErrorMessage(null);
setIsLoading(false);
return;
}
async (options?: { showLoading?: boolean }) => {
if (options?.showLoading !== false) {
setIsLoading(true);
}
const requestId = ++overviewRequestSequence.current;
overviewRefreshPending.current = true;
setErrorMessage(null);
try {
const response = await fetchRoutineOverview();
if (
!mountedRef.current ||
requestId !== overviewRequestSequence.current
)
return;
const schedules = response.schedules;
const activeExecutions = response.activeExecutions;
const upcomingRuns = response.upcomingRuns;
@@ -767,24 +758,67 @@ export function RoutineSchedulesContent({
activeExecutions,
upcomingRuns,
lastExecutions,
fetchedAt: now,
};
return response;
} catch (error) {
if (
!mountedRef.current ||
requestId !== overviewRequestSequence.current
)
return;
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
} finally {
setIsLoading(false);
if (
mountedRef.current &&
requestId === overviewRequestSequence.current
) {
overviewRefreshPending.current = false;
setIsLoading(false);
}
}
},
[],
);
useEffect(() => {
const refresh = () => {
void refreshSchedules({ showLoading: false });
};
const refreshVisible = () => {
// Background refreshes must not supersede a slow request on every
// poll, otherwise neither its response nor its error can be shown.
if (
document.visibilityState === "visible" &&
!overviewRefreshPending.current
) {
refresh();
}
};
const timeoutId = window.setTimeout(() => {
void refreshSchedules();
refresh();
}, 0);
return () => window.clearTimeout(timeoutId);
// Completion events deliver errors promptly, including failures before
// session creation. Polling also picks up queued/running transitions
// and changes missed during a transport disconnect.
const unsubscribers = [
"schedule.execution_completed",
"schedule.execution_failed",
].map((event) => desktopClient.subscribe(event, refresh));
const intervalId = window.setInterval(
refreshVisible,
ROUTINE_OVERVIEW_POLL_INTERVAL_MS,
);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
overviewRequestSequence.current += 1;
window.clearTimeout(timeoutId);
window.clearInterval(intervalId);
for (const unsubscribe of unsubscribers) unsubscribe();
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refreshSchedules]);
const upsertScheduleEnabled = async (
@@ -805,7 +839,7 @@ export function RoutineSchedulesContent({
schedule_id: schedule.scheduleId,
});
}
await refreshSchedules({ force: true, showLoading: false });
await refreshSchedules({ showLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
@@ -835,7 +869,7 @@ export function RoutineSchedulesContent({
description: `"${schedule.name}" did not queue a run — the schedule may be disabled or deleted.`,
variant: "destructive",
});
await refreshSchedules({ force: true, showLoading: false });
await refreshSchedules({ showLoading: false });
return;
}
toast({
@@ -859,7 +893,6 @@ export function RoutineSchedulesContent({
Date.now() < deadline
) {
const overview = await refreshSchedules({
force: true,
showLoading: false,
});
const executions = [
@@ -901,7 +934,7 @@ export function RoutineSchedulesContent({
schedule_id: scheduleId,
});
setSchedulePendingDelete(null);
await refreshSchedules({ force: true, showLoading: false });
await refreshSchedules({ showLoading: false });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
@@ -1093,7 +1126,7 @@ export function RoutineSchedulesContent({
enabled: createForm.enabled,
tags: tags ?? [],
});
await refreshSchedules({ force: true, showLoading: false });
await refreshSchedules({ showLoading: false });
setIsCreateOpen(false);
setEditingSchedule(null);
} catch (error) {
@@ -1185,6 +1218,7 @@ export function RoutineSchedulesContent({
<Button
variant="outline"
size="sm"
aria-label="Refresh schedules"
onClick={() => void refreshSchedules()}
disabled={isLoading}
>
@@ -1409,7 +1443,7 @@ export function RoutineSchedulesContent({
</p>
)}
{lastExecution?.errorMessage && (
<p className="text-destructive">
<p className="whitespace-pre-wrap break-words text-destructive">
<span className="text-muted-foreground/70">
Last error:
</span>{" "}
@@ -1572,7 +1606,7 @@ export function RoutineSchedulesContent({
{execution.status || "Unknown result"}
</span>
{execution.errorMessage && (
<span className="block truncate text-xs text-destructive">
<span className="block whitespace-pre-wrap break-words text-xs text-destructive">
{execution.errorMessage}
</span>
)}