mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
Merge remote-tracking branch 'origin/main' into feat/desktop-cloud-summary
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user