From 6b2a20cf0da6d3e23d7248d0da0e5e485df5b2a0 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 21 Jul 2026 16:48:52 +0900 Subject: [PATCH] perf(chat): resume before shared resource hydration --- .../connection/connection-actions.ts | 42 ++++++++++++------- .../chat/host/bundles/thread-bundle.ts | 33 +++++++++++++-- src/features/chat/host/session-runtime.ts | 1 + .../connection/connection-actions.test.ts | 26 ++++++++++++ .../chat/host/view-connection.test.ts | 28 ++++++++----- 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/src/features/chat/application/connection/connection-actions.ts b/src/features/chat/application/connection/connection-actions.ts index d10e3136..cb3a336d 100644 --- a/src/features/chat/application/connection/connection-actions.ts +++ b/src/features/chat/application/connection/connection-actions.ts @@ -53,6 +53,7 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void { } export interface ChatConnectionActions { + ensureInitialized(): Promise; ensureConnected(): Promise; invalidate(): void; handleExit(): void; @@ -63,29 +64,40 @@ export interface ChatConnectionActions { export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions { let generation = 0; - let activeConnection: { generation: number; promise: Promise } | null = null; + let activeConnection: { generation: number; initialization: Promise; hydration: Promise } | null = null; const invalidate = (): void => { generation += 1; activeConnection = null; }; const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation; + const startConnection = (): NonNullable => { + const connectionGeneration = generation; + const connectionIsStale = (): boolean => isStale(connectionGeneration); + const initialization = initializeConnection(host, connectionIsStale); + const hydration = initialization.then(async () => { + if (connectionIsStale() || !host.connection.isConnected()) return; + await hydrateConnectedResources(host, connectionIsStale); + }); + const active = { generation: connectionGeneration, initialization, hydration }; + activeConnection = active; + const clear = (): void => { + if (activeConnection === active) activeConnection = null; + }; + void hydration.then(clear, clear); + return active; + }; const actions: ChatConnectionActions = { + ensureInitialized: async () => { + if (!host.canConnect()) return; + if (activeConnection) return activeConnection.initialization; + if (host.connection.isConnected()) return; + await startConnection().initialization; + }, ensureConnected: async () => { if (!host.canConnect()) return; - if (activeConnection) return activeConnection.promise; + if (activeConnection) return activeConnection.hydration; if (host.connection.isConnected()) return; - - const connectionGeneration = generation; - const promise = initializeConnection(host, () => isStale(connectionGeneration)); - const active = { generation: connectionGeneration, promise }; - activeConnection = active; - try { - await promise; - } finally { - if (activeConnection === active) { - activeConnection = null; - } - } + await startConnection().hydration; }, invalidate, handleExit: () => { @@ -150,8 +162,6 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: () host.notifyConnectionFailed(); return; } - - await hydrateConnectedResources(host, isStale); } async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index a06e80bc..bec25647 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -79,6 +79,7 @@ interface ChatPanelThreadLifecycleInput { appServer: ChatAppServerGateway; localItemIds: LocalIdSource; ensureConnected: () => Promise; + ensureInitialized: () => Promise; status: ChatPanelThreadStatus; threadStart: ThreadStartActions; foundation: ChatPanelThreadFoundation; @@ -191,9 +192,19 @@ export function createThreadLifecycleBundle( host: ChatPanelThreadHost, input: ChatPanelThreadLifecycleInput, ): ChatPanelThreadLifecycleBundle { - const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input; + const { + appServer, + localItemIds, + ensureConnected, + ensureInitialized, + status, + threadStart, + foundation, + notifyActiveThreadIdentityChanged, + } = input; const lifecycle = createSessionThreadLifecycle(host, { appServer, + ensureInitialized, status, goalSync: foundation.goalSync, autoTitleCoordinator: foundation.autoTitleCoordinator, @@ -298,6 +309,7 @@ function createSessionThreadLifecycle( host: ChatPanelThreadHost, input: { appServer: ChatAppServerGateway; + ensureInitialized: () => Promise; status: ChatPanelThreadStatus; goalSync: ChatPanelGoalSyncActions; autoTitleCoordinator: AutoTitleCoordinator; @@ -306,7 +318,16 @@ function createSessionThreadLifecycle( notifyActiveThreadIdentityChanged: () => void; }, ): ChatPanelThreadLifecycle { - const { appServer, status, goalSync, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input; + const { + appServer, + ensureInitialized, + status, + goalSync, + autoTitleCoordinator, + history, + invalidateThreadWork, + notifyActiveThreadIdentityChanged, + } = input; const restoration = new RestorationController({ stateStore: host.stateStore, }); @@ -315,7 +336,13 @@ function createSessionThreadLifecycle( }; const resume = createResumeActions({ stateStore: host.stateStore, - resumeTransport: appServer.threadResume, + resumeTransport: { + ensureConnected: async () => { + await ensureInitialized(); + return appServer.connectionAvailable(); + }, + resumeThread: (threadId) => appServer.threadResume.resumeThread(threadId), + }, resumeWork: host.resumeWork, history, closing: host.getClosing, diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index 7b99bdd2..907b2934 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -187,6 +187,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat appServer, localItemIds, ensureConnected, + ensureInitialized: () => connectionActions.ensureInitialized(), status, threadStart, foundation: threadFoundation, diff --git a/tests/features/chat/application/connection/connection-actions.test.ts b/tests/features/chat/application/connection/connection-actions.test.ts index 7f933ad3..dfe768bc 100644 --- a/tests/features/chat/application/connection/connection-actions.test.ts +++ b/tests/features/chat/application/connection/connection-actions.test.ts @@ -123,6 +123,32 @@ describe("ChatConnectionActions", () => { expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" }); }); + it("publishes initialization readiness before shared resources finish hydrating", async () => { + const { actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness(); + const metadata = deferred(); + refreshAppServerMetadata.mockReturnValueOnce(metadata.promise); + + await actions.ensureInitialized(); + + expect(stateStore.getState().connection.initializeResponse).toMatchObject({ codexHome: "/codex" }); + expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" }); + expect(host.refreshSharedThreads).not.toHaveBeenCalled(); + expect(host.scheduleDeferredDiagnostics).not.toHaveBeenCalled(); + + let connected = false; + const fullyConnected = actions.ensureConnected().then(() => { + connected = true; + }); + await Promise.resolve(); + expect(connected).toBe(false); + + metadata.resolve(undefined); + await fullyConnected; + + expect(host.refreshSharedThreads).toHaveBeenCalledOnce(); + expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce(); + }); + it("keeps the initialized connection usable when metadata hydration fails", async () => { const { actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness(); refreshAppServerMetadata.mockRejectedValueOnce(new Error("config unavailable")); diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index a4d65dd3..b65bd3e0 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -39,7 +39,7 @@ describe("CodexChatView connection lifecycle", () => { expectRequestTimes(client, "thread/list", 1); }); - it("keeps connect calls joined while metadata is still loading", async () => { + it("resumes while metadata is still loading without reconnecting", async () => { const config = deferred(); const client = connectedClient({ "config/read": vi.fn(() => config.promise), @@ -47,25 +47,31 @@ describe("CodexChatView connection lifecycle", () => { connectionMockState().client = client; const view = await chatView(); - const firstConnect = view.surface.connect(); + const opening = view.surface.openThread("thread-1"); await waitForAsyncWork(() => { expectRequestTimes(client, "config/read", 1); + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); }); - - let secondResolved = false; - const secondConnect = view.surface.connect().then(() => { - secondResolved = true; + await opening; + let connected = false; + const fullyConnected = view.surface.connect().then(() => { + connected = true; }); await Promise.resolve(); expect(connectionMockState().connected).toBe(true); - expect(secondResolved).toBe(false); + expect(connectionMockState().connectCalls).toBe(1); + expect(connected).toBe(false); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); + expectRequestTimes(client, "thread/list", 0); config.resolve({}); - await Promise.all([firstConnect, secondConnect]); + await fullyConnected; + await waitForAsyncWork(() => { + expectRequestTimes(client, "thread/list", 1); + }); expectRequestTimes(client, "config/read", 1); - expectRequestTimes(client, "thread/list", 1); }); it("loads app-server metadata after connecting", async () => { @@ -209,7 +215,9 @@ describe("CodexChatView connection lifecycle", () => { const view = await chatView(); const connecting = view.surface.connect(); - await Promise.resolve(); + await waitForAsyncWork(() => { + expectRequestTimes(client, "config/read", 1); + }); await view.onClose(); resolveConfig({}); await connecting;