mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract composer submission controller
This commit is contained in:
parent
e86eb40a2f
commit
ad25b95eb2
3 changed files with 181 additions and 38 deletions
64
src/features/chat/composer-submission-controller.ts
Normal file
64
src/features/chat/composer-submission-controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
99
tests/features/chat/composer-submission-controller.test.ts
Normal file
99
tests/features/chat/composer-submission-controller.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue