diff --git a/vscode/src/extension.test.ts b/vscode/src/extension.test.ts index 5f0ce741be6..add5fcc61af 100644 --- a/vscode/src/extension.test.ts +++ b/vscode/src/extension.test.ts @@ -28,6 +28,7 @@ interface Disposable { } const settings = new Map(); +const executedCommands: unknown[][] = []; const configurationChanges = new Emitter<{ affectsConfiguration(section: string): boolean }>(); let registeredProvider: unknown = null; @@ -62,7 +63,11 @@ void mock.module("vscode", () => ({ }, commands: { registerCommand: () => ({ dispose: () => undefined }), - executeCommand: () => Promise.resolve(undefined), + executeCommand: (...args: unknown[]) => { + executedCommands.push(args); + notify(); + return Promise.resolve(undefined); + }, }, })); @@ -100,10 +105,19 @@ async function until(predicate: () => boolean, label: string): Promise { }); } -function startServer(initialToken: string) { +function startServer(initialToken: string, workspaces: unknown[] = [WORKSPACE]) { const hits = new Map(); - const state: { token: string; hold: Promise | null; heldCalls: number } = { + const state: { + token: string; + hold: Promise | null; + heldCalls: number; + metadata: unknown[]; + metadataOpen: number; + } = { token: initialToken, + // Queued workspace.onMetadata updates, delivered after the snapshot (#5109). + metadata: [], + metadataOpen: 0, // getOutput waits on this while set, so a test can hold a call in flight. hold: null, heldCalls: 0, @@ -117,7 +131,22 @@ function startServer(initialToken: string) { const router = { general: { ping: authed.handler(() => "pong") }, workspace: { - list: authed.handler(() => [WORKSPACE]), + list: authed.handler(() => workspaces), + onMetadata: authed.handler(async function* ({ signal }) { + // Open subscriptions; the client closing one aborts its request. + state.metadataOpen += 1; + notify(); + signal?.addEventListener("abort", () => { + state.metadataOpen -= 1; + notify(); + }); + yield { type: "snapshot", workspaces }; + for (;;) { + const update = state.metadata.shift(); + if (update) yield update; + else await until(() => state.metadata.length > 0, "a metadata update").catch(() => undefined); + } + }), activity: { list: authed.handler(() => ({})) }, backgroundBashes: { getOutput: authed.handler(async () => { @@ -204,8 +233,8 @@ afterEach(() => { settings.clear(); }); -async function setup() { - const server = startServer("token-a"); +async function setup(workspaces?: unknown[]) { + const server = startServer("token-a", workspaces); servers.push(server); settings.set("mux.connectionMode", "server-only"); settings.set("mux.serverUrl", server.url); @@ -301,6 +330,7 @@ async function setup() { response, call, cancel: (requestId: string) => send({ type: "orpcCancel", requestId }), + send, setSecret: (token: string) => secrets.set(SECRET_KEY, token), fireSecretChange: () => secretChanges.fire({ key: SECRET_KEY }), }; @@ -441,3 +471,93 @@ describe("chat view bridged oRPC calls reuse the validated API client (#5196)", expect(server.validations()).toBe(validations); }); }); + +describe("chat view live workspace list (#5109)", () => { + const child = (extra: Record = {}) => ({ + ...WORKSPACE, + id: "ws-child", + name: "child", + parentWorkspaceId: WORKSPACE.id, + ...extra, + }); + type Posted = PostedMessage & { workspaces?: Array<{ id: string }> }; + const lists = (posted: Posted[]) => posted.filter((message) => message.type === "workspaces"); + + test("a burst of metadata events re-posts the list only when the projection changes", async () => { + // Users have ~4.5k workspaces; these are older, so they sort after the others. + const synthetic = Array.from({ length: 4_500 }, (_, i) => ({ + ...WORKSPACE, + id: `ws-s${i}`, + name: `s${i}`, + createdAt: "2026-09-01T00:00:00.000Z", + })); + const harness = await setup([WORKSPACE, ...synthetic]); + const posted = harness.posted as Posted[]; + const before = lists(posted).length; + const update = (metadata: unknown, workspaceId = "ws-child") => + harness.server.state.metadata.push({ workspaceId, metadata }); + + // 21 events; only the creation, the rename, the archive and the last creation change what + // the webview is sent. + for (let i = 0; i < 5; i++) update({ ...synthetic[i], namedWorkspacePath: `/p${i}` }, `ws-s${i}`); + update(child()); // created + for (let i = 0; i < 5; i++) update(child({ namedWorkspacePath: `/tmp/c${i}` })); + update(child({ title: "Explorer" })); // renamed + for (let i = 0; i < 5; i++) update(child({ title: "Explorer", namedWorkspacePath: `/x${i}` })); + update(child({ archivedAt: "2026-10-04T00:00:00.000Z" })); // archived: drops out + update(null, "ws-gone"); // removal of an unknown workspace changes nothing + update(null); + // A last creation marks the end of the burst: every event before it has been handled. + update(child({ id: "ws-last", name: "last" }), "ws-last"); + notify(); + await until(() => lists(posted).length - before >= 4, "the end of the burst"); + + const ids = lists(posted) + .slice(before) + .map((message) => { + const listed = message.workspaces?.map((w) => w.id) ?? []; + expect(listed.filter((id) => id.startsWith("ws-s"))).toHaveLength(synthetic.length); + return listed.filter((id) => !id.startsWith("ws-s")); + }); + // Sorted by recency, then name. + expect(ids).toEqual([ + ["ws-child", "ws-1"], + ["ws-child", "ws-1"], + ["ws-1"], + ["ws-last", "ws-1"], + ]); + }); + + test("a changed server URL or auth token closes the subscription until the next refresh", async () => { + for (const change of ["url", "secret"] as const) { + const harness = await setup(); + const { state } = harness.server; + await until(() => state.metadataOpen === 1, "the metadata subscription"); + if (change === "url") { + configurationChanges.fire({ affectsConfiguration: (section) => section === "mux.serverUrl" }); + } else { + harness.fireSecretChange(); + } + await until(() => state.metadataOpen === 0, `the ${change} change to close it`); + await harness.refresh("refreshWorkspaces"); + await until(() => state.metadataOpen === 1, "a new subscription after the refresh"); + } + }); + + test("an update the webview never sees still refreshes the host's copy", async () => { + const titled = { ...WORKSPACE, title: "Main", runtimeConfig: { type: "worktree", srcBaseDir: "/src" } }; + const harness = await setup([titled]); + const posted = harness.posted as Posted[]; + const before = lists(posted).length; + // With a display title set, a rename changes only `name`, which the webview is never sent. + harness.server.state.metadata.push({ workspaceId: WORKSPACE.id, metadata: { ...titled, name: "renamed" } }); + // A creation marks the end; its re-sort must keep the renamed copy. + harness.server.state.metadata.push({ workspaceId: "ws-child", metadata: child() }); + notify(); + await until(() => lists(posted).length > before, "the creation post"); + executedCommands.length = 0; + harness.send({ type: "openWorkspace", workspaceId: WORKSPACE.id }); + await until(() => executedCommands.length > 0, "the workspace to open"); + expect(String(executedCommands[0][1])).toBe("file:///src/xum/renamed"); + }); +}); diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 735340bb218..88f2459bdc1 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from "node:crypto"; import { formatRelativeTime } from "xum/browser/utils/ui/dateTime"; import { + enrichAndSort, getAllWorkspacesFromFiles, getAllWorkspacesFromApi, getWorkspacePath, @@ -31,6 +32,7 @@ import { import { parseWebviewToExtensionMessage } from "./parseWebviewToExtensionMessage"; import { openWorkspace } from "./workspaceOpener"; import { pumpWorkspaceActivity } from "./workspaceActivityPump"; +import { pumpWorkspaceMetadata } from "./workspaceMetadataPump"; let sessionPreferredMode: "api" | "file" | null = null; let didShowFallbackPrompt = false; @@ -770,7 +772,8 @@ async function debugConnectionCommand(context: vscode.ExtensionContext): Promise async function getWorkspacesForSidebar( context: vscode.ExtensionContext -): Promise<{ workspaces: WorkspaceWithContext[]; status: UiConnectionStatus }> { + // In api mode, `client` listed them; the live pumps reuse its validation. +): Promise<{ workspaces: WorkspaceWithContext[]; status: UiConnectionStatus; client?: ApiClient }> { assert(context, "getWorkspacesForSidebar requires context"); const modeSetting: ConnectionMode = getConnectionModeSetting(); @@ -846,6 +849,7 @@ async function getWorkspacesForSidebar( mode: "api", baseUrl: api.baseUrl, }, + client: api.client, }; } catch (error) { const apiError = formatError(error); @@ -980,6 +984,11 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab >(); private subscribedWorkspaceId: string | null = null; private subscriptionAbort: AbortController | null = null; + // Live workspace list (#5109), on the client of the last successful refresh. + private metadataAbort: AbortController | null = null; + // What the webview was last sent: the whole list, and per workspace for metadata events. + private lastPostedWorkspacesKey: string | null = null; + private postedProjections = new Map(); constructor(private readonly context: vscode.ExtensionContext) { this.selectedWorkspaceId = @@ -1000,6 +1009,9 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab dropValidatedApi(): void { this.validatedApi = null; + // A new server URL or token (or a refresh) also ends the live list's subscription, so no event + // from the old endpoint lands; the next refresh starts one on the new client (#5109). + this.startMetadataPump(null); } private dropValidatedApiIfCurrent(acquired: Promise | null): void { @@ -1433,7 +1445,10 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab this.workspacesById = new Map(this.workspaces.map((w) => [w.id, w])); this.postMessage({ type: "connectionStatus", status: this.connectionStatus }); - this.postMessage({ type: "workspaces", workspaces: this.workspaces.map(toUiWorkspace) }); + // Always posted: a refresh may come from a new webview that has no list yet. + this.lastPostedWorkspacesKey = null; + this.postWorkspacesIfChanged(); + this.startMetadataPump(result.status.mode === "api" ? (result.client ?? null) : null); if (this.selectedWorkspaceId && !this.workspacesById.has(this.selectedWorkspaceId)) { await this.setSelectedWorkspaceId(null); @@ -1461,6 +1476,7 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab this.setConnectionStatus({ mode: "file", error: message }); this.workspaces = []; this.workspacesById = new Map(); + this.startMetadataPump(null); this.subscriptionAbort?.abort(); this.subscriptionAbort = null; @@ -1727,6 +1743,68 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab } } + private postWorkspacesIfChanged(): void { + const workspaces = this.workspaces.map(toUiWorkspace); + const projections = new Map(workspaces.map((w) => [w.id, JSON.stringify(w)])); + const key = [...projections.values()].join("\n"); + if (key === this.lastPostedWorkspacesKey) { + return; + } + this.lastPostedWorkspacesKey = key; + this.postedProjections = projections; + this.postMessage({ type: "workspaces", workspaces }); + } + + /** Re-sorts and posts the list; a real change only (or the snapshot), never per event. */ + private async relist(workspaces: WorkspaceWithContext[]): Promise { + // Keep each workspace's recency and stream flag from the last refresh. + const extensionMeta = new Map( + this.workspaces.flatMap((w) => (w.extensionMetadata ? [[w.id, w.extensionMetadata]] : [])) + ); + this.workspaces = enrichAndSort(workspaces, extensionMeta); + this.workspacesById = new Map(this.workspaces.map((w) => [w.id, w])); + this.postWorkspacesIfChanged(); + if (this.selectedWorkspaceId && !this.workspacesById.has(this.selectedWorkspaceId)) { + await this.setSelectedWorkspaceId(null); + } + } + + /** One workspace.onMetadata subscription per refresh keeps the list live; null stops it. */ + private startMetadataPump(client: ApiClient | null): void { + this.metadataAbort?.abort(); + this.metadataAbort = null; + if (!client) { + return; + } + const controller = new AbortController(); + this.metadataAbort = controller; + void pumpWorkspaceMetadata({ + client, + signal: controller.signal, + onSnapshot: (workspaces) => this.relist(workspaces), + onUpdate: async (workspaceId, metadata) => { + const previous = this.workspacesById.get(workspaceId); + const next = metadata && { ...metadata, extensionMetadata: previous?.extensionMetadata }; + // Compare only this workspace with its last posted projection: most events change fields + // the webview never sees, and rebuilding a list of thousands per event is too slow. Such + // an event still refreshes the stored copy, e.g. the name that opening the workspace reads. + const posted = this.postedProjections.get(workspaceId); + if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) { + if (next) this.workspacesById.set(workspaceId, next); + return; + } + // From the map, which holds those refreshed copies. + const others = [...this.workspacesById.values()].filter((w) => w.id !== workspaceId); + await this.relist(next ? [...others, next] : others); + }, + onError: (error) => { + xumLogDebug("mux.chatView: workspace metadata subscription failed", { + error: formatError(error), + }); + }, + }); + } + private async updateChatSubscription(): Promise { if (!this.isWebviewReady || !this.view) { return; diff --git a/vscode/src/workspaceMetadataPump.test.ts b/vscode/src/workspaceMetadataPump.test.ts new file mode 100644 index 00000000000..68ccbf2276f --- /dev/null +++ b/vscode/src/workspaceMetadataPump.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +import type { FrontendWorkspaceMetadata } from "xum/common/types/workspace"; +import { pumpWorkspaceMetadata, type WorkspaceMetadataPumpClient } from "./workspaceMetadataPump"; + +const workspace = (id: string) => ({ id, name: id }) as FrontendWorkspaceMetadata; + +// The host replaces the subscription on every refresh (#5109); the old one must go quiet. +describe("pumpWorkspaceMetadata", () => { + test("an aborted subscription ignores its late events and errors", async () => { + const controller = new AbortController(); + const client: WorkspaceMetadataPumpClient = { + workspace: { + onMetadata: () => + Promise.resolve( + (async function* () { + yield { type: "snapshot" as const, workspaces: [workspace("parent")] }; + controller.abort(); // replaced while the next event was in flight + yield { workspaceId: "child", metadata: workspace("child") }; + throw new Error("stream closed"); + })() + ), + }, + }; + const seen: string[] = []; + const errors: unknown[] = []; + await pumpWorkspaceMetadata({ + client, + signal: controller.signal, + onSnapshot: (workspaces) => { + seen.push(...workspaces.map((w) => w.id)); + }, + onUpdate: (workspaceId) => { + seen.push(workspaceId); + }, + onError: (error) => errors.push(error), + }); + expect(seen).toEqual(["parent"]); + expect(errors).toEqual([]); + }); +}); diff --git a/vscode/src/workspaceMetadataPump.ts b/vscode/src/workspaceMetadataPump.ts new file mode 100644 index 00000000000..bbda852e59b --- /dev/null +++ b/vscode/src/workspaceMetadataPump.ts @@ -0,0 +1,56 @@ +import type { FrontendWorkspaceMetadata } from "xum/common/types/workspace"; +import { isWorkspaceArchived } from "xum/common/utils/archive"; + +type MetadataEvent = + | { type: "snapshot"; workspaces: FrontendWorkspaceMetadata[] } + | { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }; + +/** The slice of the oRPC client the pump uses; ApiClient satisfies it structurally. */ +export interface WorkspaceMetadataPumpClient { + workspace: { + onMetadata( + input: undefined, + options: { signal: AbortSignal } + ): Promise>; + }; +} + +const isListed = (workspace: FrontendWorkspaceMetadata) => + !isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt); + +/** + * Keeps the host's workspace list live (#5109): sub-agent task status, creation, removal and + * archiving reach the webview without a manual refresh. One subscription for all workspaces; the + * first event is the full snapshot, then one event per changed workspace. Updates pass through + * one at a time, so the host can compare just that workspace (users have thousands). Archived + * workspaces arrive as removals, as workspace.list omits them. Lives outside extension.ts so it + * can be unit tested. Best-effort: failures before abort go to `onError`. + */ +export async function pumpWorkspaceMetadata(params: { + client: WorkspaceMetadataPumpClient; + signal: AbortSignal; + onSnapshot: (workspaces: FrontendWorkspaceMetadata[]) => void | Promise; + onUpdate: ( + workspaceId: string, + metadata: FrontendWorkspaceMetadata | null + ) => void | Promise; + onError: (error: unknown) => void; +}): Promise { + const { signal } = params; + try { + const iterator = await params.client.workspace.onMetadata(undefined, { signal }); + for await (const event of iterator) { + // A replaced subscription can still deliver an event it already had in flight. + if (signal.aborted) return; + if ("type" in event) { + await params.onSnapshot(event.workspaces.filter(isListed)); + } else { + const { metadata } = event; + await params.onUpdate(event.workspaceId, metadata && isListed(metadata) ? metadata : null); + } + } + } catch (error) { + if (signal.aborted) return; + params.onError(error); + } +} diff --git a/vscode/src/xumConfig.ts b/vscode/src/xumConfig.ts index 241efdc9884..5f69a02caf9 100644 --- a/vscode/src/xumConfig.ts +++ b/vscode/src/xumConfig.ts @@ -37,7 +37,7 @@ export interface WorkspaceWithContext extends FrontendWorkspaceMetadata { extensionMetadata?: ExtensionMetadata; } -function enrichAndSort( +export function enrichAndSort( workspaces: FrontendWorkspaceMetadata[], extensionMeta: Map ): WorkspaceWithContext[] {