fix(desktop): reuse recent cloud session listings

This commit is contained in:
John Choi
2026-10-22 18:57:23 -07:00
parent 590785eff5
commit d8d4077dfd
5 changed files with 147 additions and 11 deletions
@@ -65,7 +65,7 @@ export class CloudSessionManager extends CloudSessionController {
});
}
override async listForDiscovery(
options: { timeoutMs?: number } = {},
options: Parameters<CloudSessionController["listForDiscovery"]>[0] = {},
): Promise<Record<string, unknown>[]> {
return (await super.listForDiscovery(options)).map((record) => ({
...record,
@@ -388,6 +388,7 @@ const OPENABLE_URL_PROTOCOLS = new Set(["https:", "http:", "mailto:", "tel:"]);
// Fall back to cached rows rather than stall the sidebar on the cloud API.
const CLOUD_DISCOVERY_BUDGET_MS = 2_000;
const CLOUD_DISCOVERY_MAX_AGE_MS = 30_000;
function openUrlInDefaultBrowser(url: string): Promise<void> {
const platform = process.platform;
@@ -2360,7 +2361,10 @@ export async function handleCommand(
const local = discoverChatSessions(ctx, limit);
if (!isCloudAgentsEnabled()) return local;
const cloud = await getCloudSessionManager(ctx)
.listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS })
.listForDiscovery({
timeoutMs: CLOUD_DISCOVERY_BUDGET_MS,
maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS,
})
.catch((error) => {
// A persistent listing failure silently empties the sidebar's
// cloud rows; keep a diagnostic trail.
@@ -2387,7 +2391,10 @@ export async function handleCommand(
)) as JsonRecord[];
if (!isCloudAgentsEnabled()) return local;
const cloud = await getCloudSessionManager(ctx)
.listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS })
.listForDiscovery({
timeoutMs: CLOUD_DISCOVERY_BUDGET_MS,
maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS,
})
.catch((error) => {
ctx.logger?.error?.("Cloud session discovery failed", { error });
return [];
+7
View File
@@ -135,6 +135,13 @@ the outer session is deleted). `dispose` preserves an injected map; without one,
the controller owns and clears its pending state. Restoring options alone never
authorizes recreation of a missing established task.
`CloudSessionController.listForDiscovery({ maxAgeMs })` can reuse the last
successful list for the specified duration. Omitting `maxAgeMs` starts or joins
a refresh. Pending refreshes retain their existing `timeoutMs` budget behavior.
Cached discovery still projects current live state and locally created sessions.
Desktop uses a 30-second window for combined history lists; local discovery,
cloud search, and session-open reads keep their existing refresh behavior.
## Experimental cloud handoff
`loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud
@@ -210,6 +210,109 @@ function resumableFixture(status = "ready") {
}
describe("CloudSessionController neutral host contract", () => {
it.each([
{ rows: [] },
{ rows: [record] },
])("reuses a successful discovery list within maxAgeMs: %j", async ({
rows,
}) => {
const f = fixture();
f.api.list.mockResolvedValue(rows);
const now = vi.spyOn(Date, "now").mockReturnValue(0);
try {
const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
now.mockReturnValue(29_999);
expect(await f.controller.listForDiscovery({ maxAgeMs: 30_000 })).toEqual(
initial,
);
expect(f.api.list).toHaveBeenCalledTimes(1);
now.mockReturnValue(30_000);
await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
expect(f.api.list).toHaveBeenCalledTimes(2);
await f.controller.listForDiscovery();
expect(f.api.list).toHaveBeenCalledTimes(3);
await f.controller.dispose();
await expect(
f.controller.listForDiscovery({ maxAgeMs: 30_000 }),
).rejects.toThrow("disposed");
} finally {
now.mockRestore();
await f.controller.dispose();
}
});
it("does not renew discovery freshness after a failed refresh", async () => {
const f = fixture();
const now = vi.spyOn(Date, "now").mockReturnValue(0);
try {
const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
now.mockReturnValue(30_000);
f.api.list.mockRejectedValueOnce(new Error("Network unavailable"));
expect(
await f.controller.listForDiscovery({ maxAgeMs: 30_000, timeoutMs: 1 }),
).toEqual(initial);
await f.controller.listForDiscovery({ maxAgeMs: 30_000 });
expect(f.api.list).toHaveBeenCalledTimes(3);
} finally {
now.mockRestore();
await f.controller.dispose();
}
});
it("joins a pending discovery refresh even while the previous cache is fresh", async () => {
const f = fixture();
await f.controller.listForDiscovery();
let complete!: (rows: CloudSessionRecord[]) => void;
f.api.list.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const refresh = f.controller.listForDiscovery();
const cached = f.controller.listForDiscovery({ maxAgeMs: 30_000 });
await vi.waitFor(() => expect(f.api.list).toHaveBeenCalledTimes(2));
complete([{ ...record, title: "Updated remotely" }]);
await refresh;
expect((await cached)[0]?.metadata).toMatchObject({
title: "Updated remotely",
});
await f.controller.dispose();
});
it.each([
false,
true,
])("keeps cached discovery consistent after deletion (fails: %s)", async (fails) => {
const f = fixture();
await f.controller.listForDiscovery();
if (fails) {
f.api.delete.mockRejectedValueOnce(new Error("Network unavailable"));
await expect(f.controller.delete(record.id)).rejects.toThrow(
"Network unavailable",
);
} else {
await f.controller.delete(record.id);
}
expect(
await f.controller.listForDiscovery({ maxAgeMs: 30_000 }),
).toHaveLength(fails ? 1 : 0);
expect(f.api.list).toHaveBeenCalledTimes(1);
await f.controller.dispose();
});
it("keeps cached discovery titles current after a readiness refresh and rename", async () => {
const f = fixture();
await f.controller.listForDiscovery();
await f.controller.waitUntilReady(record.id);
await f.controller.updateTitle(record.id, "New title");
expect(
(await f.controller.listForDiscovery({ maxAgeMs: 30_000 }))[0]?.metadata,
).toMatchObject({ title: "New title" });
expect(f.api.list).toHaveBeenCalledTimes(2);
await f.controller.dispose();
});
it("refreshes an attached session title after an external rename", async () => {
const f = await attached();
await f.controller.updateTitle(record.id, "Desktop title");
+27 -8
View File
@@ -438,6 +438,7 @@ export class CloudSessionController {
private readonly unlistedSessions = new Map<string, CloudSessionRecord>();
private readonly pendingInitialTasks: Map<string, CloudCreationOptions>;
private lastListedSessions: CloudSessionRecord[] = [];
private lastListedAt?: number;
private discoveryRefresh?: Promise<CloudSessionRecord[]>;
private readonly unconfirmedInnerCreates = new Set<string>();
private readonly createRequests = new Map<
@@ -802,6 +803,7 @@ export class CloudSessionController {
}
}
this.lastListedSessions = scoped;
this.lastListedAt = Date.now();
for (const record of scoped)
if (this.sessions.has(record.id)) this.publishSnapshot(record.id);
return immutableCopy(scoped);
@@ -907,21 +909,29 @@ export class CloudSessionController {
}
async listForDiscovery(
options: { timeoutMs?: number } = {},
options: { timeoutMs?: number; maxAgeMs?: number } = {},
): Promise<JsonRecord[]> {
if (this.disposed) throw new Error("Cloud session controller was disposed");
const maxAgeMs = options.maxAgeMs ?? 0;
const cacheIsFresh =
maxAgeMs > 0 &&
this.lastListedAt !== undefined &&
Date.now() - this.lastListedAt < maxAgeMs;
const refresh =
this.discoveryRefresh ??
this.list().finally(() => {
if (this.discoveryRefresh === refresh) {
this.discoveryRefresh = undefined;
}
});
(cacheIsFresh
? undefined
: this.list().finally(() => {
if (this.discoveryRefresh === refresh) {
this.discoveryRefresh = undefined;
}
}));
this.discoveryRefresh = refresh;
let records = this.lastListedSessions;
if (options.timeoutMs === undefined) {
if (refresh && options.timeoutMs === undefined) {
records = await refresh;
} else {
} else if (refresh) {
let timeout: ReturnType<typeof setTimeout> | undefined;
const result = await Promise.race([
refresh.then(
@@ -2007,6 +2017,12 @@ export class CloudSessionController {
if (record) {
record.title = title;
}
const listed = this.lastListedSessions.find(
(session) => session.id === outerSessionId,
);
if (listed) {
listed.title = title;
}
const live = this.sessions.get(outerSessionId);
if (live) {
live.title = title;
@@ -2044,6 +2060,9 @@ export class CloudSessionController {
throw error;
}
}
this.lastListedSessions = this.lastListedSessions.filter(
(record) => record.id !== outerSessionId,
);
this.knownSessions.delete(outerSessionId);
this.unlistedSessions.delete(outerSessionId);
this.pendingInitialTasks.delete(outerSessionId);