Extract composer submission controller

This commit is contained in:
murashit 2026-05-29 06:18:27 +09:00
parent e86eb40a2f
commit ad25b95eb2
3 changed files with 181 additions and 38 deletions

View file

@ -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<void>;
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
export class ComposerSubmissionController {
constructor(private readonly host: ComposerSubmissionControllerHost) {}
async submit(): Promise<void> {
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<void> {
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<void> {
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));
}
}
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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 {

View file

@ -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");
});
});