mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract connection controller
This commit is contained in:
parent
ad25b95eb2
commit
7ec151b0ea
3 changed files with 255 additions and 71 deletions
125
src/features/chat/connection-controller.ts
Normal file
125
src/features/chat/connection-controller.ts
Normal file
|
|
@ -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<InitializeResponse>;
|
||||
currentClient(): AppServerClient | null;
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
export interface ChatConnectionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
connection: ChatConnectionAdapter;
|
||||
connectionWork: ChatConnectionWorkTracker;
|
||||
appServer: ChatAppServerController;
|
||||
setClient: (client: AppServerClient | null) => void;
|
||||
loadSharedThreadList: () => Promise<void>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.refreshDiagnostics();
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
await this.refreshThreads();
|
||||
}
|
||||
|
||||
async refreshSkills(forceReload = false): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
@ -687,41 +681,19 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
private async refreshThreads(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
87
tests/features/chat/connection-controller.test.ts
Normal file
87
tests/features/chat/connection-controller.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue