From 7ec151b0eae439f7ef98f9f06fe31b009af36bad Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 06:22:44 +0900 Subject: [PATCH] Extract connection controller --- src/features/chat/connection-controller.ts | 125 ++++++++++++++++++ src/features/chat/view.ts | 114 ++++++---------- .../chat/connection-controller.test.ts | 87 ++++++++++++ 3 files changed, 255 insertions(+), 71 deletions(-) create mode 100644 src/features/chat/connection-controller.ts create mode 100644 tests/features/chat/connection-controller.test.ts diff --git a/src/features/chat/connection-controller.ts b/src/features/chat/connection-controller.ts new file mode 100644 index 00000000..64df2859 --- /dev/null +++ b/src/features/chat/connection-controller.ts @@ -0,0 +1,125 @@ +import { StaleConnectionError } from "../../app-server/connection-manager"; +import type { AppServerClient } from "../../app-server/client"; +import type { InitializeResponse } from "../../generated/app-server/InitializeResponse"; +import type { ChatAction, ChatStateStore } from "./chat-state"; +import type { ChatAppServerController } from "./chat-app-server-controller"; +import type { ChatConnectionWorkTracker, ActiveChatConnection } from "./view-lifecycle"; + +export interface ChatConnectionAdapter { + connect(): Promise; + currentClient(): AppServerClient | null; + isConnected(): boolean; +} + +export interface ChatConnectionControllerHost { + stateStore: ChatStateStore; + connection: ChatConnectionAdapter; + connectionWork: ChatConnectionWorkTracker; + appServer: ChatAppServerController; + setClient: (client: AppServerClient | null) => void; + loadSharedThreadList: () => Promise; + scheduleDeferredDiagnostics: () => void; + clearDeferredDiagnostics: () => void; + refreshTabHeader: () => void; + setStatus: (status: string) => void; + addSystemMessage: (text: string) => void; + render: () => void; + scheduleRender: () => void; + notifyConnectionFailed: () => void; +} + +export class ChatConnectionController { + constructor(private readonly host: ChatConnectionControllerHost) {} + + async ensureConnected(): Promise { + const connecting = this.host.connectionWork.active(); + if (connecting?.promise) return connecting.promise; + + if (this.host.connection.isConnected()) { + this.host.setClient(this.host.connection.currentClient()); + return; + } + + const connection = this.host.connectionWork.begin(); + const promise = this.initializeConnection(connection); + connection.promise = promise; + try { + await promise; + } finally { + this.host.connectionWork.finish(connection, promise); + } + } + + invalidate(): void { + this.host.connectionWork.invalidate(); + } + + async refreshThreads(): Promise { + this.host.setClient(this.host.connection.currentClient()); + if (!this.host.connection.currentClient()) return; + try { + await this.host.loadSharedThreadList(); + await this.host.appServer.refreshPublishedAppServerMetadata(); + this.host.refreshTabHeader(); + this.host.render(); + } catch (error) { + this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + async refreshDiagnostics(): Promise { + this.host.clearDeferredDiagnostics(); + await this.ensureConnected(); + if (!this.host.connection.currentClient()) return; + this.host.clearDeferredDiagnostics(); + await this.host.appServer.refreshPublishedCapabilityDiagnostics(); + this.host.render(); + } + + async refreshStatusPanel(): Promise { + try { + await this.refreshDiagnostics(); + } catch (error) { + this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + await this.refreshThreads(); + } + + async refreshSkills(forceReload = false): Promise { + this.host.setClient(this.host.connection.currentClient()); + if (!this.host.connection.currentClient()) return; + await this.host.appServer.refreshPublishedSkills(forceReload); + this.host.render(); + } + + private async initializeConnection(connection: ActiveChatConnection): Promise { + this.host.setStatus("Starting Codex app-server..."); + try { + this.dispatch({ type: "connection/initialized", initializeResponse: await this.host.connection.connect() }); + if (this.host.connectionWork.isStale(connection)) return; + const client = this.host.connection.currentClient(); + this.host.setClient(client); + if (!client) throw new Error("Codex app-server connection did not initialize."); + await this.host.appServer.refreshPublishedAppServerMetadata(); + if (this.host.connectionWork.isStale(connection)) return; + await this.host.loadSharedThreadList(); + if (this.host.connectionWork.isStale(connection)) return; + this.host.scheduleDeferredDiagnostics(); + this.host.refreshTabHeader(); + this.host.setStatus("Connected."); + } catch (error) { + if (this.host.connectionWork.isStale(connection)) return; + if (error instanceof StaleConnectionError) return; + this.host.setStatus("Connection failed."); + this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.notifyConnectionFailed(); + } + if (!this.host.connectionWork.isStale(connection)) { + this.host.scheduleRender(); + } + } + + private dispatch(action: ChatAction): void { + this.host.stateStore.dispatch(action); + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 14831784..26e50932 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -1,7 +1,7 @@ import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; import type { AppServerClient } from "../../app-server/client"; -import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager"; +import { ConnectionManager } from "../../app-server/connection-manager"; import { VIEW_TYPE_CODEX_PANEL } from "../../constants"; import { createSystemItem } from "./display/system"; import type { DisplayDetailSection, DisplayItem } from "./display/types"; @@ -53,7 +53,6 @@ import { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks, - type ActiveChatConnection, type ActiveChatResume, type ChatViewRenderScheduleOptions, } from "./view-lifecycle"; @@ -65,6 +64,7 @@ import { ChatMessageScrollController } from "./message-scroll-controller"; import { TurnSubmissionController } from "./turn-submission-controller"; import { SlashCommandController } from "./slash-command-controller"; import { ComposerSubmissionController } from "./composer-submission-controller"; +import { ChatConnectionController } from "./connection-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -88,6 +88,7 @@ export class CodexChatView extends ItemView { private readonly connection: ConnectionManager; private readonly controller: ChatController; private readonly appServer: ChatAppServerController; + private readonly connectionController: ChatConnectionController; private readonly history: ThreadHistoryLoader; private readonly threadActions: ChatThreadActionController; private readonly runtimeSettings: ChatRuntimeSettingsController; @@ -308,6 +309,40 @@ export class CodexChatView extends ItemView { this.plugin.publishAppServerMetadata(metadata); }, }); + this.connectionController = new ChatConnectionController({ + stateStore: this.chatState, + connection: this.connection, + connectionWork: this.connectionWork, + appServer: this.appServer, + setClient: (client) => { + this.client = client; + }, + loadSharedThreadList: () => this.loadSharedThreadList(), + scheduleDeferredDiagnostics: () => { + this.scheduleDeferredDiagnostics(); + }, + clearDeferredDiagnostics: () => { + this.clearDeferredDiagnostics(); + }, + refreshTabHeader: () => { + this.refreshTabHeader(); + }, + setStatus: (status) => { + this.setStatus(status); + }, + addSystemMessage: (text) => { + this.addSystemMessage(text); + }, + render: () => { + this.render(); + }, + scheduleRender: () => { + this.scheduleRender(); + }, + notifyConnectionFailed: () => { + new Notice("Codex app-server connection failed."); + }, + }); this.history = new ThreadHistoryLoader({ stateStore: this.chatState, currentClient: () => this.client, @@ -627,52 +662,11 @@ export class CodexChatView extends ItemView { } private async ensureConnected(): Promise { - const connecting = this.connectionWork.active(); - if (connecting?.promise) return connecting.promise; - - if (this.connection.isConnected()) { - this.client = this.connection.currentClient(); - return; - } - - const connection = this.connectionWork.begin(); - const promise = this.initializeConnection(connection); - connection.promise = promise; - try { - await promise; - } finally { - this.connectionWork.finish(connection, promise); - } - } - - private async initializeConnection(connection: ActiveChatConnection): Promise { - this.setStatus("Starting Codex app-server..."); - try { - this.dispatch({ type: "connection/initialized", initializeResponse: await this.connection.connect() }); - if (this.connectionWork.isStale(connection)) return; - this.client = this.connection.currentClient(); - if (!this.client) throw new Error("Codex app-server connection did not initialize."); - await this.appServer.refreshPublishedAppServerMetadata(); - if (this.connectionWork.isStale(connection)) return; - await this.loadSharedThreadList(); - if (this.connectionWork.isStale(connection)) return; - this.scheduleDeferredDiagnostics(); - this.refreshTabHeader(); - this.setStatus("Connected."); - } catch (error) { - if (this.connectionWork.isStale(connection)) return; - if (error instanceof StaleConnectionError) return; - this.setStatus("Connection failed."); - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - new Notice("Codex app-server connection failed."); - } - if (!this.connectionWork.isStale(connection)) { - this.scheduleRender(); - } + await this.connectionController.ensureConnected(); } private invalidateConnectionWork(): void { - this.connectionWork.invalidate(); + this.connectionController.invalidate(); } async startNewThread(): Promise { @@ -687,41 +681,19 @@ export class CodexChatView extends ItemView { } private async refreshThreads(): Promise { - this.client = this.connection.currentClient(); - if (!this.client) return; - try { - await this.loadSharedThreadList(); - await this.appServer.refreshPublishedAppServerMetadata(); - this.refreshTabHeader(); - this.render(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } + await this.connectionController.refreshThreads(); } private async refreshDiagnostics(): Promise { - this.clearDeferredDiagnostics(); - await this.ensureConnected(); - if (!this.client) return; - this.clearDeferredDiagnostics(); - await this.appServer.refreshPublishedCapabilityDiagnostics(); - this.render(); + await this.connectionController.refreshDiagnostics(); } private async refreshStatusPanel(): Promise { - try { - await this.refreshDiagnostics(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - await this.refreshThreads(); + await this.connectionController.refreshStatusPanel(); } private async refreshSkills(forceReload = false): Promise { - this.client = this.connection.currentClient(); - if (!this.client) return; - await this.appServer.refreshPublishedSkills(forceReload); - this.render(); + await this.connectionController.refreshSkills(forceReload); } private async resumeThread(threadId: string): Promise { diff --git a/tests/features/chat/connection-controller.test.ts b/tests/features/chat/connection-controller.test.ts new file mode 100644 index 00000000..1a263edb --- /dev/null +++ b/tests/features/chat/connection-controller.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AppServerClient } from "../../../src/app-server/client"; +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { ChatConnectionController, type ChatConnectionAdapter } from "../../../src/features/chat/connection-controller"; +import { ChatConnectionWorkTracker } from "../../../src/features/chat/view-lifecycle"; +import type { ChatAppServerController } from "../../../src/features/chat/chat-app-server-controller"; + +function createController({ connected = false, client = {} as AppServerClient } = {}) { + const stateStore = createChatStateStore(createChatState()); + let currentClient: AppServerClient | null = connected ? client : null; + const connect = vi.fn().mockImplementation(async () => { + currentClient = client; + return { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" }; + }); + const connection: ChatConnectionAdapter = { + connect, + currentClient: () => currentClient, + isConnected: () => Boolean(currentClient), + }; + const refreshPublishedAppServerMetadata = vi.fn().mockResolvedValue(null); + const refreshPublishedCapabilityDiagnostics = vi.fn().mockResolvedValue(undefined); + const refreshPublishedSkills = vi.fn().mockResolvedValue(undefined); + const appServer = { + refreshPublishedAppServerMetadata, + refreshPublishedCapabilityDiagnostics, + refreshPublishedSkills, + } as unknown as ChatAppServerController; + const setClient = vi.fn((next: AppServerClient | null) => { + currentClient = next; + }); + const host = { + stateStore, + connection, + connectionWork: new ChatConnectionWorkTracker(), + appServer, + setClient, + loadSharedThreadList: vi.fn().mockResolvedValue(undefined), + scheduleDeferredDiagnostics: vi.fn(), + clearDeferredDiagnostics: vi.fn(), + refreshTabHeader: vi.fn(), + setStatus: vi.fn(), + addSystemMessage: vi.fn(), + render: vi.fn(), + scheduleRender: vi.fn(), + notifyConnectionFailed: vi.fn(), + }; + return { + connect, + controller: new ChatConnectionController(host), + host, + refreshPublishedAppServerMetadata, + refreshPublishedCapabilityDiagnostics, + stateStore, + }; +} + +describe("ChatConnectionController", () => { + it("connects once and publishes startup metadata", async () => { + const { connect, controller, host, refreshPublishedAppServerMetadata, stateStore } = createController(); + + await controller.ensureConnected(); + + expect(connect).toHaveBeenCalledOnce(); + expect(stateStore.getState().initializeResponse).toEqual({ + codexHome: "/codex", + platformFamily: "unix", + platformOs: "macos", + userAgent: "test", + }); + expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce(); + expect(host.loadSharedThreadList).toHaveBeenCalledOnce(); + expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce(); + expect(host.setStatus).toHaveBeenCalledWith("Connected."); + expect(host.scheduleRender).toHaveBeenCalledOnce(); + }); + + it("refreshes diagnostics after clearing deferred diagnostics", async () => { + const { controller, host, refreshPublishedCapabilityDiagnostics } = createController({ connected: true }); + + await controller.refreshDiagnostics(); + + expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2); + expect(refreshPublishedCapabilityDiagnostics).toHaveBeenCalledOnce(); + expect(host.render).toHaveBeenCalledOnce(); + }); +});