From ad25b95eb26b5bdc011fa48e035eb5a208044598 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 06:18:27 +0900 Subject: [PATCH] Extract composer submission controller --- .../chat/composer-submission-controller.ts | 64 ++++++++++++ src/features/chat/view.ts | 56 ++++------- .../composer-submission-controller.test.ts | 99 +++++++++++++++++++ 3 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 src/features/chat/composer-submission-controller.ts create mode 100644 tests/features/chat/composer-submission-controller.test.ts diff --git a/src/features/chat/composer-submission-controller.ts b/src/features/chat/composer-submission-controller.ts new file mode 100644 index 00000000..f13e9f6c --- /dev/null +++ b/src/features/chat/composer-submission-controller.ts @@ -0,0 +1,64 @@ +import type { AppServerClient } from "../../app-server/client"; +import { parseSlashCommand } from "./composer/suggestions"; +import { activeTurnId, chatTurnBusy, type ChatStateStore } from "./chat-state"; +import type { ChatComposerController } from "./chat-composer-controller"; +import type { SlashCommandController } from "./slash-command-controller"; +import type { TurnSubmissionController } from "./turn-submission-controller"; + +export interface ComposerSubmissionControllerHost { + stateStore: ChatStateStore; + composer: ChatComposerController; + slashCommands: SlashCommandController; + turnSubmission: TurnSubmissionController; + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + setStatus: (status: string) => void; + addSystemMessage: (text: string) => void; +} + +export class ComposerSubmissionController { + constructor(private readonly host: ComposerSubmissionControllerHost) {} + + async submit(): Promise { + const draft = this.host.composer.trimmedDraft; + const state = this.host.stateStore.getState(); + if (chatTurnBusy(state) && state.activeThreadId && activeTurnId(state) && draft.length === 0) { + await this.interruptTurn(); + return; + } + await this.sendMessage(); + } + + private async sendMessage(): Promise { + const text = this.host.composer.trimmedDraft; + if (!text) return; + + await this.host.ensureConnected(); + if (!this.host.currentClient()) return; + + const slashCommand = parseSlashCommand(text); + if (slashCommand) { + this.host.composer.setDraft("", { clearSuggestions: true }); + const result = await this.host.slashCommands.execute(slashCommand.command, slashCommand.args); + if (result?.sendText) { + await this.host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread); + } + return; + } + + await this.host.turnSubmission.sendTurnText(text); + } + + private async interruptTurn(): Promise { + const state = this.host.stateStore.getState(); + const turnId = activeTurnId(state); + const client = this.host.currentClient(); + if (!client || !state.activeThreadId || !turnId) return; + try { + await client.interruptTurn(state.activeThreadId, turnId); + this.host.setStatus("Interrupt requested."); + } catch (error) { + this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 3462424d..14831784 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -2,7 +2,6 @@ import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsi import type { AppServerClient } from "../../app-server/client"; import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager"; -import { parseSlashCommand } from "./composer/suggestions"; import { VIEW_TYPE_CODEX_PANEL } from "../../constants"; import { createSystemItem } from "./display/system"; import type { DisplayDetailSection, DisplayItem } from "./display/types"; @@ -65,6 +64,7 @@ import { ChatReconnectController } from "./reconnect-controller"; import { ChatMessageScrollController } from "./message-scroll-controller"; import { TurnSubmissionController } from "./turn-submission-controller"; import { SlashCommandController } from "./slash-command-controller"; +import { ComposerSubmissionController } from "./composer-submission-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -104,6 +104,7 @@ export class CodexChatView extends ItemView { private readonly messageScroll: ChatMessageScrollController; private readonly turnSubmission: TurnSubmissionController; private readonly slashCommands: SlashCommandController; + private readonly composerSubmission: ComposerSubmissionController; private shellRenderVersion = 0; private readonly connectionWork = new ChatConnectionWorkTracker(); private readonly resumeWork: ChatResumeWorkTracker; @@ -217,9 +218,23 @@ export class CodexChatView extends ItemView { onComposerResize: () => { if (this.state.messagesPinnedToBottom) this.messageScroll.forceBottom(); }, - onSubmit: () => void this.submitComposerAction(), + onSubmit: () => void this.composerSubmission.submit(), onNewThread: () => void this.startNewThread(), }); + this.composerSubmission = new ComposerSubmissionController({ + stateStore: this.chatState, + composer: this.composerController, + slashCommands: this.slashCommands, + turnSubmission: this.turnSubmission, + currentClient: () => this.client, + ensureConnected: () => this.ensureConnected(), + setStatus: (status) => { + this.setStatus(status); + }, + addSystemMessage: (text) => { + this.addSystemMessage(text); + }, + }); this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, { onNotification: (notification) => { this.controller.handleNotification(notification); @@ -785,26 +800,6 @@ export class CodexChatView extends ItemView { void this.app.workspace.requestSaveLayout(); } - private async sendMessage(): Promise { - const text = this.composerController.trimmedDraft; - if (!text) return; - - await this.ensureConnected(); - if (!this.client) return; - - const slashCommand = parseSlashCommand(text); - if (slashCommand) { - this.composerController.setDraft("", { clearSuggestions: true }); - const result = await this.slashCommands.execute(slashCommand.command, slashCommand.args); - if (result?.sendText) { - await this.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread); - } - return; - } - - await this.turnSubmission.sendTurnText(text); - } - private async implementPlan(item: DisplayItem): Promise { if (!this.canImplementPlanItem(item)) return; await this.ensureConnected(); @@ -815,23 +810,8 @@ export class CodexChatView extends ItemView { await this.turnSubmission.sendTurnText("Please implement this plan."); } - private async interruptTurn(): Promise { - if (!this.client || !this.state.activeThreadId || !this.activeTurnId) return; - try { - await this.client.interruptTurn(this.state.activeThreadId, this.activeTurnId); - this.setStatus("Interrupt requested."); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - private async submitComposerAction(): Promise { - const draft = this.composerController.trimmedDraft; - if (this.turnBusy && this.state.activeThreadId && this.activeTurnId && draft.length === 0) { - await this.interruptTurn(); - return; - } - await this.sendMessage(); + await this.composerSubmission.submit(); } private canImplementPlanItem(item: DisplayItem): boolean { diff --git a/tests/features/chat/composer-submission-controller.test.ts b/tests/features/chat/composer-submission-controller.test.ts new file mode 100644 index 00000000..5b09dfa1 --- /dev/null +++ b/tests/features/chat/composer-submission-controller.test.ts @@ -0,0 +1,99 @@ +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 { ComposerSubmissionController } from "../../../src/features/chat/composer-submission-controller"; +import type { ChatComposerController } from "../../../src/features/chat/chat-composer-controller"; +import type { SlashCommandController } from "../../../src/features/chat/slash-command-controller"; +import type { TurnSubmissionController } from "../../../src/features/chat/turn-submission-controller"; +import type { Thread } from "../../../src/generated/app-server/v2/Thread"; + +function thread(id: string): Thread { + return { + id, + sessionId: id, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 0, + updatedAt: 0, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "test", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +function createController(draft: string) { + const stateStore = createChatStateStore(createChatState()); + const interruptTurn = vi.fn().mockResolvedValue({}); + const client = { interruptTurn } as unknown as AppServerClient; + const setDraft = vi.fn(); + const sendTurnText = vi.fn().mockResolvedValue(undefined); + const execute = vi.fn().mockResolvedValue(undefined); + const controller = new ComposerSubmissionController({ + stateStore, + composer: { + get trimmedDraft() { + return draft; + }, + setDraft, + } as unknown as ChatComposerController, + slashCommands: { execute } as unknown as SlashCommandController, + turnSubmission: { sendTurnText } as unknown as TurnSubmissionController, + currentClient: () => client, + ensureConnected: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn(), + addSystemMessage: vi.fn(), + }); + return { controller, execute, interruptTurn, sendTurnText, setDraft, stateStore }; +} + +describe("ComposerSubmissionController", () => { + it("sends plain drafts as turn text", async () => { + const { controller, sendTurnText } = createController("hello"); + + await controller.submit(); + + expect(sendTurnText).toHaveBeenCalledWith("hello"); + }); + + it("executes slash commands and forwards command send results", async () => { + const { controller, execute, sendTurnText, setDraft } = createController("/new hello"); + execute.mockResolvedValue({ sendText: "hello" }); + + await controller.submit(); + + expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true }); + expect(execute).toHaveBeenCalledWith("new", "hello"); + expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined); + }); + + it("interrupts a running turn when submitting an empty draft", async () => { + const { controller, interruptTurn, stateStore } = createController(""); + stateStore.dispatch({ + type: "thread/resumed", + thread: thread("thread"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalPolicy: null, + approvalsReviewer: null, + activePermissionProfile: null, + }); + stateStore.dispatch({ type: "turn/started", threadId: "thread", turnId: "turn" }); + + await controller.submit(); + + expect(interruptTurn).toHaveBeenCalledWith("thread", "turn"); + }); +});