From 02df0cc4b77362c05f5f0867739d2a9dbf1dc19e Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 8 Jun 2026 09:08:11 +0900 Subject: [PATCH] Functionize stateless chat controllers --- src/features/chat/panel/composition.ts | 16 ++-- src/features/chat/panel/open-close-actions.ts | 79 +++++++++++++++++++ src/features/chat/panel/ui-composition.ts | 4 +- .../chat/panel/view-open-close-controller.ts | 67 ---------------- .../chat/requests/server-request-actions.ts | 40 ++++++++++ .../chat/requests/server-request-responder.ts | 32 -------- src/features/chat/session/composition.ts | 4 +- .../chat/session/reconnect-actions.ts | 48 +++++++++++ .../chat/session/reconnect-controller.ts | 42 ---------- src/features/chat/threads/composition.ts | 8 +- .../chat/threads/thread-identity-actions.ts | 75 ++++++++++++++++++ .../threads/thread-identity-controller.ts | 60 -------------- src/features/chat/turns/composition.ts | 8 +- .../chat/turns/plan-implementation-actions.ts | 44 +++++++++++ .../turns/plan-implementation-controller.ts | 40 ---------- ...ler.test.ts => open-close-actions.test.ts} | 13 ++- ...test.ts => server-request-actions.test.ts} | 12 +-- ...ller.test.ts => reconnect-actions.test.ts} | 12 +-- ...est.ts => thread-identity-actions.test.ts} | 6 +- ...ts => plan-implementation-actions.test.ts} | 12 +-- 20 files changed, 332 insertions(+), 290 deletions(-) create mode 100644 src/features/chat/panel/open-close-actions.ts delete mode 100644 src/features/chat/panel/view-open-close-controller.ts create mode 100644 src/features/chat/requests/server-request-actions.ts delete mode 100644 src/features/chat/requests/server-request-responder.ts create mode 100644 src/features/chat/session/reconnect-actions.ts delete mode 100644 src/features/chat/session/reconnect-controller.ts create mode 100644 src/features/chat/threads/thread-identity-actions.ts delete mode 100644 src/features/chat/threads/thread-identity-controller.ts create mode 100644 src/features/chat/turns/plan-implementation-actions.ts delete mode 100644 src/features/chat/turns/plan-implementation-controller.ts rename tests/features/chat/panel/{view-open-close-controller.test.ts => open-close-actions.test.ts} (87%) rename tests/features/chat/requests/{server-request-responder.test.ts => server-request-actions.test.ts} (76%) rename tests/features/chat/session/{reconnect-controller.test.ts => reconnect-actions.test.ts} (83%) rename tests/features/chat/threads/{thread-identity-controller.test.ts => thread-identity-actions.test.ts} (92%) rename tests/features/chat/turns/{plan-implementation-controller.test.ts => plan-implementation-actions.test.ts} (91%) diff --git a/src/features/chat/panel/composition.ts b/src/features/chat/panel/composition.ts index dbd379ac..6c538e77 100644 --- a/src/features/chat/panel/composition.ts +++ b/src/features/chat/panel/composition.ts @@ -12,15 +12,15 @@ import type { ThreadRenameController } from "../threads/thread-rename-controller import type { ToolbarPanelController } from "./toolbar-controller"; import type { AppServerWarmupActions } from "../session/app-server-warmup-controller"; import type { ChatConnectionController } from "../session/connection-controller"; -import type { ChatReconnectController } from "../session/reconnect-controller"; +import type { ChatReconnectActions } from "../session/reconnect-actions"; import type { PendingRequestController } from "../requests/pending-request-controller"; -import { ServerRequestResponder } from "../requests/server-request-responder"; +import { createServerRequestActions } from "../requests/server-request-actions"; import type { ComposerSubmissionController } from "../turns/composer-submission-controller"; import type { RestoredThreadController } from "../threads/restored-thread-controller"; -import type { ThreadIdentityController } from "../threads/thread-identity-controller"; +import type { ThreadIdentityActions } from "../threads/thread-identity-actions"; import type { ThreadResumeController } from "../threads/thread-resume-controller"; import type { ThreadSelectionActions } from "../threads/thread-selection-controller"; -import type { ChatViewOpenCloseController } from "./view-open-close-controller"; +import type { ChatViewOpenCloseActions } from "./open-close-actions"; import type { ChatViewRenderController } from "./view-render-controller"; import type { ChatViewStateActions } from "./view-state-controller"; import type { ChatMessageRenderer } from "../ui/message-stream"; @@ -34,7 +34,7 @@ export interface ChatViewControllers { connection: { manager: ConnectionManager; controller: ChatConnectionController; - reconnect: ChatReconnectController; + reconnect: ChatReconnectActions; warmup: AppServerWarmupActions; }; inbound: { @@ -50,7 +50,7 @@ export interface ChatViewControllers { resume: ThreadResumeController; actions: ChatThreadActionController; restored: RestoredThreadController; - identity: ThreadIdentityController; + identity: ThreadIdentityActions; rename: ThreadRenameController; selection: ThreadSelectionActions; }; @@ -71,7 +71,7 @@ export interface ChatViewControllers { render: { controller: ChatViewRenderController; messages: ChatMessageRenderer; - openClose: ChatViewOpenCloseController; + openClose: ChatViewOpenCloseActions; viewState: ChatViewStateActions; }; } @@ -99,7 +99,7 @@ export function createChatViewControllers(ports: ChatPanelContext): ChatViewCont connection, goals, }); - const serverRequestResponder = new ServerRequestResponder({ + const serverRequestResponder = createServerRequestActions({ currentClient: ports.client.getClient, }); const controller = createChatInboundController(ports, { diff --git a/src/features/chat/panel/open-close-actions.ts b/src/features/chat/panel/open-close-actions.ts new file mode 100644 index 00000000..23af1c0e --- /dev/null +++ b/src/features/chat/panel/open-close-actions.ts @@ -0,0 +1,79 @@ +import type { EventRef, WorkspaceLeaf } from "obsidian"; + +import { unmountChatPanelShell } from "../ui/shell"; + +export interface ChatViewOpenCloseActionsHost { + setOpened: (opened: boolean) => void; + setClosing: (closing: boolean) => void; + registerEvent: (eventRef: EventRef) => void; + registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void; + registerPointerDown: (handler: (event: PointerEvent) => void) => void; + registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void; + isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean; + scrollMessagesToBottomOnFocus: () => void; + applyCachedSharedAppServerState: () => void; + render: () => void; + scheduleDeferredAppServerWarmup: () => void; + scheduleDeferredRestoredThreadHydration: () => void; + closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void; + invalidateConnectionWork: () => void; + invalidateResumeWork: () => void; + clearDeferredTasks: () => void; + panelRoot: () => HTMLElement | null; + disposeMessages: () => void; + disposeComposer: () => void; + disconnect: () => void; + clearClient: () => void; + refreshLiveState: () => void; + deferRefreshLiveState: () => void; +} + +export interface ChatViewOpenCloseActions { + open: () => void; + close: () => void; +} + +export function createChatViewOpenCloseActions(host: ChatViewOpenCloseActionsHost): ChatViewOpenCloseActions { + return { + open: () => { + openChatView(host); + }, + close: () => { + closeChatView(host); + }, + }; +} + +function openChatView(host: ChatViewOpenCloseActionsHost): void { + host.setOpened(true); + host.setClosing(false); + host.registerComposerNoteIndexInvalidation((eventRef) => { + host.registerEvent(eventRef); + }); + host.registerPointerDown((event) => { + host.closeToolbarPanelOnOutsidePointer(event); + }); + host.registerActiveLeafChange((leaf) => { + if (host.isOwnLeaf(leaf)) host.scrollMessagesToBottomOnFocus(); + }); + host.applyCachedSharedAppServerState(); + host.render(); + host.scheduleDeferredAppServerWarmup(); + host.scheduleDeferredRestoredThreadHydration(); +} + +function closeChatView(host: ChatViewOpenCloseActionsHost): void { + host.setOpened(false); + host.setClosing(true); + host.invalidateConnectionWork(); + host.invalidateResumeWork(); + host.clearDeferredTasks(); + const panelRoot = host.panelRoot(); + host.disposeMessages(); + host.disposeComposer(); + unmountChatPanelShell(panelRoot); + host.disconnect(); + host.clearClient(); + host.refreshLiveState(); + host.deferRefreshLiveState(); +} diff --git a/src/features/chat/panel/ui-composition.ts b/src/features/chat/panel/ui-composition.ts index fac85457..bfa14a5d 100644 --- a/src/features/chat/panel/ui-composition.ts +++ b/src/features/chat/panel/ui-composition.ts @@ -3,7 +3,7 @@ import type { ChatAppServerMetadataController } from "../app-server/metadata-con import type { ChatAppServerThreadController } from "../app-server/thread-controller"; import type { ChatComposerController } from "../composer/controller"; import { createAppServerWarmupActions } from "../session/app-server-warmup-controller"; -import { ChatViewOpenCloseController } from "./view-open-close-controller"; +import { createChatViewOpenCloseActions } from "./open-close-actions"; import { ChatViewRenderController } from "./view-render-controller"; import type { ChatMessageRenderer } from "../ui/message-stream"; import { applyCachedSharedAppServerState } from "./cached-app-server-state"; @@ -60,7 +60,7 @@ export function createConnectionLifecycleControllerGroup( connected: () => refs.connection.isConnected(), ensureConnected: client.ensureConnected, }), - openCloseController: new ChatViewOpenCloseController({ + openCloseController: createChatViewOpenCloseActions({ setOpened: lifecycle.setOpened, setClosing: lifecycle.setClosing, registerEvent: obsidian.registerEvent, diff --git a/src/features/chat/panel/view-open-close-controller.ts b/src/features/chat/panel/view-open-close-controller.ts deleted file mode 100644 index d7252485..00000000 --- a/src/features/chat/panel/view-open-close-controller.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { EventRef, WorkspaceLeaf } from "obsidian"; - -import { unmountChatPanelShell } from "../ui/shell"; - -export interface ChatViewOpenCloseControllerHost { - setOpened: (opened: boolean) => void; - setClosing: (closing: boolean) => void; - registerEvent: (eventRef: EventRef) => void; - registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void; - registerPointerDown: (handler: (event: PointerEvent) => void) => void; - registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void; - isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean; - scrollMessagesToBottomOnFocus: () => void; - applyCachedSharedAppServerState: () => void; - render: () => void; - scheduleDeferredAppServerWarmup: () => void; - scheduleDeferredRestoredThreadHydration: () => void; - closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void; - invalidateConnectionWork: () => void; - invalidateResumeWork: () => void; - clearDeferredTasks: () => void; - panelRoot: () => HTMLElement | null; - disposeMessages: () => void; - disposeComposer: () => void; - disconnect: () => void; - clearClient: () => void; - refreshLiveState: () => void; - deferRefreshLiveState: () => void; -} - -export class ChatViewOpenCloseController { - constructor(private readonly host: ChatViewOpenCloseControllerHost) {} - - open(): void { - this.host.setOpened(true); - this.host.setClosing(false); - this.host.registerComposerNoteIndexInvalidation((eventRef) => { - this.host.registerEvent(eventRef); - }); - this.host.registerPointerDown((event) => { - this.host.closeToolbarPanelOnOutsidePointer(event); - }); - this.host.registerActiveLeafChange((leaf) => { - if (this.host.isOwnLeaf(leaf)) this.host.scrollMessagesToBottomOnFocus(); - }); - this.host.applyCachedSharedAppServerState(); - this.host.render(); - this.host.scheduleDeferredAppServerWarmup(); - this.host.scheduleDeferredRestoredThreadHydration(); - } - - close(): void { - this.host.setOpened(false); - this.host.setClosing(true); - this.host.invalidateConnectionWork(); - this.host.invalidateResumeWork(); - this.host.clearDeferredTasks(); - const panelRoot = this.host.panelRoot(); - this.host.disposeMessages(); - this.host.disposeComposer(); - unmountChatPanelShell(panelRoot); - this.host.disconnect(); - this.host.clearClient(); - this.host.refreshLiveState(); - this.host.deferRefreshLiveState(); - } -} diff --git a/src/features/chat/requests/server-request-actions.ts b/src/features/chat/requests/server-request-actions.ts new file mode 100644 index 00000000..1bdd1370 --- /dev/null +++ b/src/features/chat/requests/server-request-actions.ts @@ -0,0 +1,40 @@ +import type { AppServerClient } from "../../../app-server/client"; + +type RespondRequestId = Parameters[0]; +type RejectRequestId = Parameters[0]; + +export interface ServerRequestActionsHost { + currentClient: () => AppServerClient | null; +} + +export interface ServerRequestActions { + respond: (requestId: RespondRequestId, result: unknown) => boolean; + reject: (requestId: RejectRequestId, code: number, message: string) => boolean; +} + +export function createServerRequestActions(host: ServerRequestActionsHost): ServerRequestActions { + return { + respond: (requestId, result) => respondToServerRequest(host, requestId, result), + reject: (requestId, code, message) => rejectServerRequest(host, requestId, code, message), + }; +} + +function respondToServerRequest(host: ServerRequestActionsHost, requestId: RespondRequestId, result: unknown): boolean { + try { + const client = host.currentClient(); + client?.respondToServerRequest(requestId, result); + return Boolean(client); + } catch { + return false; + } +} + +function rejectServerRequest(host: ServerRequestActionsHost, requestId: RejectRequestId, code: number, message: string): boolean { + try { + const client = host.currentClient(); + client?.rejectServerRequest(requestId, code, message); + return Boolean(client); + } catch { + return false; + } +} diff --git a/src/features/chat/requests/server-request-responder.ts b/src/features/chat/requests/server-request-responder.ts deleted file mode 100644 index 11862b99..00000000 --- a/src/features/chat/requests/server-request-responder.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AppServerClient } from "../../../app-server/client"; - -type RespondRequestId = Parameters[0]; -type RejectRequestId = Parameters[0]; - -export interface ServerRequestResponderHost { - currentClient: () => AppServerClient | null; -} - -export class ServerRequestResponder { - constructor(private readonly host: ServerRequestResponderHost) {} - - respond(requestId: RespondRequestId, result: unknown): boolean { - try { - const client = this.host.currentClient(); - client?.respondToServerRequest(requestId, result); - return Boolean(client); - } catch { - return false; - } - } - - reject(requestId: RejectRequestId, code: number, message: string): boolean { - try { - const client = this.host.currentClient(); - client?.rejectServerRequest(requestId, code, message); - return Boolean(client); - } catch { - return false; - } - } -} diff --git a/src/features/chat/session/composition.ts b/src/features/chat/session/composition.ts index f9bfb6c9..49d7c896 100644 --- a/src/features/chat/session/composition.ts +++ b/src/features/chat/session/composition.ts @@ -5,7 +5,7 @@ import { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-co import { ChatAppServerMetadataController } from "../app-server/metadata-controller"; import { ChatAppServerThreadController } from "../app-server/thread-controller"; import { ChatConnectionController } from "./connection-controller"; -import type { ServerRequestResponder } from "../requests/server-request-responder"; +import type { ServerRequestActions } from "../requests/server-request-actions"; import type { ChatThreadGoalController } from "../threads/thread-goal-controller"; import type { ThreadRenameController } from "../threads/thread-rename-controller"; import { ChatInboundController } from "../inbound/controller"; @@ -59,7 +59,7 @@ export function createChatInboundController( appServerMetadata: ChatAppServerMetadataController; appServerDiagnostics: ChatAppServerDiagnosticsController; threadRename: ThreadRenameController; - serverRequestResponder: ServerRequestResponder; + serverRequestResponder: ServerRequestActions; }, ): ChatInboundController { const { plugin, thread, render } = context; diff --git a/src/features/chat/session/reconnect-actions.ts b/src/features/chat/session/reconnect-actions.ts new file mode 100644 index 00000000..f16551a3 --- /dev/null +++ b/src/features/chat/session/reconnect-actions.ts @@ -0,0 +1,48 @@ +import { clearLocalTurnAction, closePanelsAction } from "../chat-state-actions"; +import { activeThreadId } from "../chat-state-selectors"; +import type { ChatStateStore } from "../chat-state"; + +export interface ChatReconnectActionsHost { + stateStore: ChatStateStore; + invalidateConnectionWork: () => void; + invalidateResumeWork: () => void; + clearDeferredDiagnostics: () => void; + reconnect: () => void; + clearClient: () => void; + setStatus: (status: string) => void; + render: () => void; + ensureConnected: () => Promise; + resumeThread: (threadId: string) => Promise; + addSystemMessage: (text: string) => void; +} + +export interface ChatReconnectActions { + reconnectPanel: () => Promise; +} + +export function createChatReconnectActions(host: ChatReconnectActionsHost): ChatReconnectActions { + return { + reconnectPanel: () => reconnectPanel(host), + }; +} + +async function reconnectPanel(host: ChatReconnectActionsHost): Promise { + const threadId = activeThreadId(host.stateStore.getState()); + host.stateStore.dispatch(closePanelsAction()); + host.invalidateConnectionWork(); + host.invalidateResumeWork(); + host.clearDeferredDiagnostics(); + host.reconnect(); + host.clearClient(); + host.stateStore.dispatch(clearLocalTurnAction()); + host.setStatus("Reconnecting..."); + host.render(); + + await host.ensureConnected(); + if (!threadId) return; + try { + await host.resumeThread(threadId); + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/features/chat/session/reconnect-controller.ts b/src/features/chat/session/reconnect-controller.ts deleted file mode 100644 index 80e4c028..00000000 --- a/src/features/chat/session/reconnect-controller.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { clearLocalTurnAction, closePanelsAction } from "../chat-state-actions"; -import { activeThreadId } from "../chat-state-selectors"; -import type { ChatStateStore } from "../chat-state"; - -export interface ChatReconnectControllerHost { - stateStore: ChatStateStore; - invalidateConnectionWork: () => void; - invalidateResumeWork: () => void; - clearDeferredDiagnostics: () => void; - reconnect: () => void; - clearClient: () => void; - setStatus: (status: string) => void; - render: () => void; - ensureConnected: () => Promise; - resumeThread: (threadId: string) => Promise; - addSystemMessage: (text: string) => void; -} - -export class ChatReconnectController { - constructor(private readonly host: ChatReconnectControllerHost) {} - - async reconnectPanel(): Promise { - const threadId = activeThreadId(this.host.stateStore.getState()); - this.host.stateStore.dispatch(closePanelsAction()); - this.host.invalidateConnectionWork(); - this.host.invalidateResumeWork(); - this.host.clearDeferredDiagnostics(); - this.host.reconnect(); - this.host.clearClient(); - this.host.stateStore.dispatch(clearLocalTurnAction()); - this.host.setStatus("Reconnecting..."); - this.host.render(); - - await this.host.ensureConnected(); - if (!threadId) return; - try { - await this.host.resumeThread(threadId); - } catch (error) { - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } -} diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/composition.ts index 85412a6b..671fcda8 100644 --- a/src/features/chat/threads/composition.ts +++ b/src/features/chat/threads/composition.ts @@ -4,12 +4,12 @@ import { ChatRuntimeSettingsController } from "../runtime/runtime-settings-contr import { ChatThreadActionController } from "./thread-actions-controller"; import { ChatThreadGoalController } from "./thread-goal-controller"; import { ThreadHistoryController } from "./thread-history-controller"; -import { ThreadIdentityController } from "./thread-identity-controller"; +import { createThreadIdentityActions } from "./thread-identity-actions"; import { ThreadRenameController } from "./thread-rename-controller"; import { ThreadResumeController } from "./thread-resume-controller"; import { createThreadSelectionActions } from "./thread-selection-controller"; import { RestoredThreadController } from "./restored-thread-controller"; -import { ChatReconnectController } from "../session/reconnect-controller"; +import { createChatReconnectActions } from "../session/reconnect-actions"; import { createChatViewStateActions } from "../panel/view-state-controller"; import { ToolbarPanelController } from "../panel/toolbar-controller"; import type { ChatPanelContext } from "../panel/context"; @@ -71,7 +71,7 @@ export function createThreadControllerGroup( resumeThread: thread.resumeThread, addSystemMessage: status.addSystemMessage, }); - const reconnectActions = new ChatReconnectController({ + const reconnectActions = createChatReconnectActions({ stateStore, invalidateConnectionWork: lifecycle.invalidateConnectionWork, invalidateResumeWork: lifecycle.invalidateResumeWork, @@ -145,7 +145,7 @@ export function createThreadControllerGroup( return response?.dataBase64 ?? ""; }), }); - const threadIdentity = new ThreadIdentityController({ + const threadIdentity = createThreadIdentityActions({ stateStore, restoredThread, invalidateResumeWork: lifecycle.invalidateResumeWork, diff --git a/src/features/chat/threads/thread-identity-actions.ts b/src/features/chat/threads/thread-identity-actions.ts new file mode 100644 index 00000000..2b274d48 --- /dev/null +++ b/src/features/chat/threads/thread-identity-actions.ts @@ -0,0 +1,75 @@ +import type { RestoredThreadController } from "./restored-thread-controller"; +import { applyThreadListAction, clearActiveThreadAction } from "../chat-state-actions"; +import { activeThreadId, listedThreads } from "../chat-state-selectors"; +import type { ChatStateStore } from "../chat-state"; + +export interface ThreadIdentityActionsHost { + stateStore: ChatStateStore; + restoredThread: RestoredThreadController; + invalidateResumeWork: () => void; + clearDeferredRestoredThreadHydration: () => void; + resetThreadTurnPresence: (hadTurns: boolean) => void; + notifyActiveThreadIdentityChanged: () => void; + refreshTabHeader: () => void; + refreshLiveState: () => void; + render: () => void; +} + +export interface ThreadIdentityActions { + clearActiveThreadContext: () => void; + notifyThreadArchived: (threadId: string) => void; + notifyThreadRenamed: (threadId: string, name: string | null) => void; +} + +export function createThreadIdentityActions(host: ThreadIdentityActionsHost): ThreadIdentityActions { + return { + clearActiveThreadContext: () => { + clearActiveThreadContext(host); + }, + notifyThreadArchived: (threadId) => { + notifyThreadArchived(host, threadId); + }, + notifyThreadRenamed: (threadId, name) => { + notifyThreadRenamed(host, threadId, name); + }, + }; +} + +function clearActiveThreadContext(host: ThreadIdentityActionsHost): void { + host.invalidateResumeWork(); + host.restoredThread.clear(); + host.clearDeferredRestoredThreadHydration(); + host.stateStore.dispatch(clearActiveThreadAction()); + host.resetThreadTurnPresence(false); + host.notifyActiveThreadIdentityChanged(); + host.refreshLiveState(); +} + +function notifyThreadArchived(host: ThreadIdentityActionsHost, threadId: string): void { + if (activeThreadId(host.stateStore.getState()) !== threadId) return; + clearActiveThreadContext(host); + host.render(); +} + +function notifyThreadRenamed(host: ThreadIdentityActionsHost, threadId: string, name: string | null): void { + let changed = false; + const renamedThreads = listedThreads(host.stateStore.getState()).map((thread) => { + if (thread.id !== threadId) return thread; + changed = true; + return { ...thread, name }; + }); + host.stateStore.dispatch(applyThreadListAction(renamedThreads)); + const restoredThread = host.restoredThread.placeholder(); + if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { + host.restoredThread.rename(threadId, name); + changed = true; + } + const activeThreadChanged = activeThreadId(host.stateStore.getState()) === threadId || host.restoredThread.isPending(threadId); + if (!changed && !activeThreadChanged) return; + if (activeThreadChanged) { + host.notifyActiveThreadIdentityChanged(); + } else { + host.refreshTabHeader(); + } + host.render(); +} diff --git a/src/features/chat/threads/thread-identity-controller.ts b/src/features/chat/threads/thread-identity-controller.ts deleted file mode 100644 index effc1ed5..00000000 --- a/src/features/chat/threads/thread-identity-controller.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { RestoredThreadController } from "./restored-thread-controller"; -import { applyThreadListAction, clearActiveThreadAction } from "../chat-state-actions"; -import { activeThreadId, listedThreads } from "../chat-state-selectors"; -import type { ChatStateStore } from "../chat-state"; - -export interface ThreadIdentityControllerHost { - stateStore: ChatStateStore; - restoredThread: RestoredThreadController; - invalidateResumeWork: () => void; - clearDeferredRestoredThreadHydration: () => void; - resetThreadTurnPresence: (hadTurns: boolean) => void; - notifyActiveThreadIdentityChanged: () => void; - refreshTabHeader: () => void; - refreshLiveState: () => void; - render: () => void; -} - -export class ThreadIdentityController { - constructor(private readonly host: ThreadIdentityControllerHost) {} - - clearActiveThreadContext(): void { - this.host.invalidateResumeWork(); - this.host.restoredThread.clear(); - this.host.clearDeferredRestoredThreadHydration(); - this.host.stateStore.dispatch(clearActiveThreadAction()); - this.host.resetThreadTurnPresence(false); - this.host.notifyActiveThreadIdentityChanged(); - this.host.refreshLiveState(); - } - - notifyThreadArchived(threadId: string): void { - if (activeThreadId(this.host.stateStore.getState()) !== threadId) return; - this.clearActiveThreadContext(); - this.host.render(); - } - - notifyThreadRenamed(threadId: string, name: string | null): void { - let changed = false; - const renamedThreads = listedThreads(this.host.stateStore.getState()).map((thread) => { - if (thread.id !== threadId) return thread; - changed = true; - return { ...thread, name }; - }); - this.host.stateStore.dispatch(applyThreadListAction(renamedThreads)); - const restoredThread = this.host.restoredThread.placeholder(); - if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { - this.host.restoredThread.rename(threadId, name); - changed = true; - } - const activeThreadChanged = - activeThreadId(this.host.stateStore.getState()) === threadId || this.host.restoredThread.isPending(threadId); - if (!changed && !activeThreadChanged) return; - if (activeThreadChanged) { - this.host.notifyActiveThreadIdentityChanged(); - } else { - this.host.refreshTabHeader(); - } - this.host.render(); - } -} diff --git a/src/features/chat/turns/composition.ts b/src/features/chat/turns/composition.ts index 777a76d1..472db0d7 100644 --- a/src/features/chat/turns/composition.ts +++ b/src/features/chat/turns/composition.ts @@ -1,11 +1,11 @@ import type { ChatAppServerThreadController } from "../app-server/thread-controller"; import { ChatComposerController } from "../composer/controller"; import { activeTurnId } from "../chat-state"; -import type { ChatReconnectController } from "../session/reconnect-controller"; +import type { ChatReconnectActions } from "../session/reconnect-actions"; import { PendingRequestController } from "../requests/pending-request-controller"; import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller"; import { ComposerSubmissionController } from "./composer-submission-controller"; -import { PlanImplementationController } from "./plan-implementation-controller"; +import { createPlanImplementationActions } from "./plan-implementation-actions"; import { SlashCommandController } from "./slash-command-controller"; import { TurnSubmissionController } from "./turn-submission-controller"; import type { ChatThreadActionController } from "../threads/thread-actions-controller"; @@ -25,7 +25,7 @@ export function createTurnControllerGroup( runtimeSettings: ChatRuntimeSettingsController; threadActions: ChatThreadActionController; threadRename: ThreadRenameController; - reconnectActions: ChatReconnectController; + reconnectActions: ChatReconnectActions; goals: ChatThreadGoalController; history: ThreadHistoryController; }, @@ -139,7 +139,7 @@ export function createTurnControllerGroup( effortStatusLines: runtime.effortStatusLines, }, }); - const planImplementation = new PlanImplementationController({ + const planImplementation = createPlanImplementationActions({ stateStore, connection: { currentClient, diff --git a/src/features/chat/turns/plan-implementation-actions.ts b/src/features/chat/turns/plan-implementation-actions.ts new file mode 100644 index 00000000..81ad3d29 --- /dev/null +++ b/src/features/chat/turns/plan-implementation-actions.ts @@ -0,0 +1,44 @@ +import type { AppServerClient } from "../../../app-server/client"; +import { closePanelsAction, setRequestedCollaborationModeDefaultAction } from "../chat-state-actions"; +import { activeThreadId, canImplementPlan } from "../chat-state-selectors"; +import type { ChatStateStore } from "../chat-state"; +import type { DisplayItem } from "../display/types"; + +const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; + +interface PlanImplementationConnectionPort { + currentClient(): AppServerClient | null; + ensureConnected(): Promise; +} + +interface PlanImplementationSubmissionPort { + sendTurnText(text: string): Promise; +} + +export interface PlanImplementationActionsHost { + stateStore: ChatStateStore; + connection: PlanImplementationConnectionPort; + submission: PlanImplementationSubmissionPort; +} + +export interface PlanImplementationActions { + canImplement: (item: DisplayItem) => boolean; + implement: (item: DisplayItem) => Promise; +} + +export function createPlanImplementationActions(host: PlanImplementationActionsHost): PlanImplementationActions { + return { + canImplement: (item) => canImplementPlan(host.stateStore.getState(), item), + implement: (item) => implementPlan(host, item), + }; +} + +async function implementPlan(host: PlanImplementationActionsHost, item: DisplayItem): Promise { + if (!canImplementPlan(host.stateStore.getState(), item)) return; + await host.connection.ensureConnected(); + if (!host.connection.currentClient() || !activeThreadId(host.stateStore.getState())) return; + + host.stateStore.dispatch(setRequestedCollaborationModeDefaultAction()); + host.stateStore.dispatch(closePanelsAction()); + await host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT); +} diff --git a/src/features/chat/turns/plan-implementation-controller.ts b/src/features/chat/turns/plan-implementation-controller.ts deleted file mode 100644 index 93234dbc..00000000 --- a/src/features/chat/turns/plan-implementation-controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { AppServerClient } from "../../../app-server/client"; -import { closePanelsAction, setRequestedCollaborationModeDefaultAction } from "../chat-state-actions"; -import { activeThreadId, canImplementPlan } from "../chat-state-selectors"; -import type { ChatStateStore } from "../chat-state"; -import type { DisplayItem } from "../display/types"; - -const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; - -interface PlanImplementationConnectionPort { - currentClient(): AppServerClient | null; - ensureConnected(): Promise; -} - -interface PlanImplementationSubmissionPort { - sendTurnText(text: string): Promise; -} - -export interface PlanImplementationControllerHost { - stateStore: ChatStateStore; - connection: PlanImplementationConnectionPort; - submission: PlanImplementationSubmissionPort; -} - -export class PlanImplementationController { - constructor(private readonly host: PlanImplementationControllerHost) {} - - canImplement(item: DisplayItem): boolean { - return canImplementPlan(this.host.stateStore.getState(), item); - } - - async implement(item: DisplayItem): Promise { - if (!this.canImplement(item)) return; - await this.host.connection.ensureConnected(); - if (!this.host.connection.currentClient() || !activeThreadId(this.host.stateStore.getState())) return; - - this.host.stateStore.dispatch(setRequestedCollaborationModeDefaultAction()); - this.host.stateStore.dispatch(closePanelsAction()); - await this.host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT); - } -} diff --git a/tests/features/chat/panel/view-open-close-controller.test.ts b/tests/features/chat/panel/open-close-actions.test.ts similarity index 87% rename from tests/features/chat/panel/view-open-close-controller.test.ts rename to tests/features/chat/panel/open-close-actions.test.ts index 4826d4ae..7fccfba2 100644 --- a/tests/features/chat/panel/view-open-close-controller.test.ts +++ b/tests/features/chat/panel/open-close-actions.test.ts @@ -3,19 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { EventRef } from "obsidian"; -import { - ChatViewOpenCloseController, - type ChatViewOpenCloseControllerHost, -} from "../../../../src/features/chat/panel/view-open-close-controller"; +import { createChatViewOpenCloseActions, type ChatViewOpenCloseActionsHost } from "../../../../src/features/chat/panel/open-close-actions"; import { unmountChatPanelShell } from "../../../../src/features/chat/ui/shell"; vi.mock("../../../../src/features/chat/ui/shell", () => ({ unmountChatPanelShell: vi.fn(), })); -function createHost(overrides: Partial = {}) { +function createHost(overrides: Partial = {}) { const root = document.createElement("div"); - const host: ChatViewOpenCloseControllerHost = { + const host: ChatViewOpenCloseActionsHost = { setOpened: vi.fn(), setClosing: vi.fn(), registerEvent: vi.fn(), @@ -43,10 +40,10 @@ function createHost(overrides: Partial = {}) { deferRefreshLiveState: vi.fn(), ...overrides, }; - return { controller: new ChatViewOpenCloseController(host), host, root }; + return { controller: createChatViewOpenCloseActions(host), host, root }; } -describe("ChatViewOpenCloseController", () => { +describe("createChatViewOpenCloseActions", () => { beforeEach(() => { vi.mocked(unmountChatPanelShell).mockClear(); }); diff --git a/tests/features/chat/requests/server-request-responder.test.ts b/tests/features/chat/requests/server-request-actions.test.ts similarity index 76% rename from tests/features/chat/requests/server-request-responder.test.ts rename to tests/features/chat/requests/server-request-actions.test.ts index b1c9fe70..6c0bd065 100644 --- a/tests/features/chat/requests/server-request-responder.test.ts +++ b/tests/features/chat/requests/server-request-actions.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../src/app-server/client"; -import { ServerRequestResponder } from "../../../../src/features/chat/requests/server-request-responder"; +import { createServerRequestActions } from "../../../../src/features/chat/requests/server-request-actions"; -describe("ServerRequestResponder", () => { +describe("createServerRequestActions", () => { it("responds through the current app-server client", () => { const respondToServerRequest = vi.fn(); - const responder = new ServerRequestResponder({ + const responder = createServerRequestActions({ currentClient: () => ({ respondToServerRequest }) as unknown as AppServerClient, }); @@ -17,7 +17,7 @@ describe("ServerRequestResponder", () => { it("rejects through the current app-server client", () => { const rejectServerRequest = vi.fn(); - const responder = new ServerRequestResponder({ + const responder = createServerRequestActions({ currentClient: () => ({ rejectServerRequest }) as unknown as AppServerClient, }); @@ -27,9 +27,9 @@ describe("ServerRequestResponder", () => { }); it("reports failure when there is no client or the client throws", () => { - expect(new ServerRequestResponder({ currentClient: () => null }).respond(7, null)).toBe(false); + expect(createServerRequestActions({ currentClient: () => null }).respond(7, null)).toBe(false); expect( - new ServerRequestResponder({ + createServerRequestActions({ currentClient: () => ({ rejectServerRequest: () => { diff --git a/tests/features/chat/session/reconnect-controller.test.ts b/tests/features/chat/session/reconnect-actions.test.ts similarity index 83% rename from tests/features/chat/session/reconnect-controller.test.ts rename to tests/features/chat/session/reconnect-actions.test.ts index 1555b3b4..3b963f60 100644 --- a/tests/features/chat/session/reconnect-controller.test.ts +++ b/tests/features/chat/session/reconnect-actions.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state"; -import { ChatReconnectController, type ChatReconnectControllerHost } from "../../../../src/features/chat/session/reconnect-controller"; +import { createChatReconnectActions, type ChatReconnectActionsHost } from "../../../../src/features/chat/session/reconnect-actions"; -function createHost(overrides: Partial = {}) { +function createHost(overrides: Partial = {}) { const stateStore = createChatStateStore(createChatState()); stateStore.dispatch({ type: "ui/panel-set", panel: "history" }); stateStore.dispatch({ @@ -17,7 +17,7 @@ function createHost(overrides: Partial = {}) { approvalsReviewer: null, activePermissionProfile: null, }); - const host: ChatReconnectControllerHost = { + const host: ChatReconnectActionsHost = { stateStore, invalidateConnectionWork: vi.fn(), invalidateResumeWork: vi.fn(), @@ -34,10 +34,10 @@ function createHost(overrides: Partial = {}) { return { host, stateStore }; } -describe("ChatReconnectController", () => { +describe("createChatReconnectActions", () => { it("resets local connection state before reconnecting and resumes the active thread", async () => { const { host, stateStore } = createHost(); - const controller = new ChatReconnectController(host); + const controller = createChatReconnectActions(host); await controller.reconnectPanel(); @@ -57,7 +57,7 @@ describe("ChatReconnectController", () => { const { host } = createHost({ resumeThread: vi.fn().mockRejectedValue(new Error("resume failed")), }); - const controller = new ChatReconnectController(host); + const controller = createChatReconnectActions(host); await controller.reconnectPanel(); diff --git a/tests/features/chat/threads/thread-identity-controller.test.ts b/tests/features/chat/threads/thread-identity-actions.test.ts similarity index 92% rename from tests/features/chat/threads/thread-identity-controller.test.ts rename to tests/features/chat/threads/thread-identity-actions.test.ts index fe81ab34..2f0e5c4f 100644 --- a/tests/features/chat/threads/thread-identity-controller.test.ts +++ b/tests/features/chat/threads/thread-identity-actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state"; -import { ThreadIdentityController } from "../../../../src/features/chat/threads/thread-identity-controller"; +import { createThreadIdentityActions } from "../../../../src/features/chat/threads/thread-identity-actions"; import type { RestoredThreadController } from "../../../../src/features/chat/threads/restored-thread-controller"; import type { RestoredThreadPlaceholderState } from "../../../../src/features/chat/panel/lifecycle"; import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; @@ -52,10 +52,10 @@ function createController() { refreshLiveState: vi.fn(), render: vi.fn(), }; - return { controller: new ThreadIdentityController(host), host, restoredPlaceholder, restoredRename, stateStore }; + return { controller: createThreadIdentityActions(host), host, restoredPlaceholder, restoredRename, stateStore }; } -describe("ThreadIdentityController", () => { +describe("createThreadIdentityActions", () => { it("clears the active thread when it is archived", () => { const { controller, host, stateStore } = createController(); stateStore.dispatch({ diff --git a/tests/features/chat/turns/plan-implementation-controller.test.ts b/tests/features/chat/turns/plan-implementation-actions.test.ts similarity index 91% rename from tests/features/chat/turns/plan-implementation-controller.test.ts rename to tests/features/chat/turns/plan-implementation-actions.test.ts index a5496c2f..2e59ae43 100644 --- a/tests/features/chat/turns/plan-implementation-controller.test.ts +++ b/tests/features/chat/turns/plan-implementation-actions.test.ts @@ -4,9 +4,9 @@ import type { AppServerClient } from "../../../../src/app-server/client"; import { createChatState, createChatStateStore, type ChatStateStore } from "../../../../src/features/chat/chat-state"; import { implementPlanCandidateFromState } from "../../../../src/features/chat/plan-implementation"; import { - PlanImplementationController, - type PlanImplementationControllerHost, -} from "../../../../src/features/chat/turns/plan-implementation-controller"; + createPlanImplementationActions, + type PlanImplementationActionsHost, +} from "../../../../src/features/chat/turns/plan-implementation-actions"; import type { DisplayItem } from "../../../../src/features/chat/display/types"; const planItem = (id: string): DisplayItem => ({ @@ -38,7 +38,7 @@ function createController({ client = {} as AppServerClient } = {}) { const stateStore = createChatStateStore(createChatState()); const ensureConnected = vi.fn().mockResolvedValue(undefined); const sendTurnText = vi.fn().mockResolvedValue(undefined); - const host: PlanImplementationControllerHost = { + const host: PlanImplementationActionsHost = { stateStore, connection: { currentClient: () => client, @@ -48,10 +48,10 @@ function createController({ client = {} as AppServerClient } = {}) { sendTurnText, }, }; - return { controller: new PlanImplementationController(host), ensureConnected, sendTurnText, stateStore }; + return { controller: createPlanImplementationActions(host), ensureConnected, sendTurnText, stateStore }; } -describe("PlanImplementationController", () => { +describe("createPlanImplementationActions", () => { it("finds the latest proposed plan only when the thread is implementable", () => { const stateStore = createChatStateStore(createChatState()); const first = planItem("first");