Extract plan implementation controller

This commit is contained in:
murashit 2026-05-29 06:44:23 +09:00
parent 92a4ed01bf
commit 640a411710
4 changed files with 149 additions and 35 deletions

View file

@ -9,6 +9,7 @@ import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { MarkdownMessageRenderer } from "./markdown-message-renderer";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
import { implementPlanCandidateFromState } from "./plan-implementation-controller";
import { unmountReactRoot } from "../../shared/ui/react-root";
export interface ChatMessageRendererOptions {
@ -127,19 +128,4 @@ export class ChatMessageRenderer {
}
}
export function implementPlanCandidateFromState(
state: Pick<ChatState, "activeThreadId" | "turnLifecycle" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
): DisplayItem | null {
if (
!state.activeThreadId ||
chatTurnBusy(state) ||
state.composerDraft.trim().length > 0 ||
state.requestedCollaborationMode !== "plan"
) {
return null;
}
return (
[...state.displayItems].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ??
null
);
}
export { implementPlanCandidateFromState };

View file

@ -0,0 +1,51 @@
import type { AppServerClient } from "../../app-server/client";
import { chatTurnBusy, type ChatState, type ChatStateStore } from "./chat-state";
import type { DisplayItem } from "./display/types";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
export interface PlanImplementationControllerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
sendTurnText: (text: string) => Promise<void>;
}
export class PlanImplementationController {
constructor(private readonly host: PlanImplementationControllerHost) {}
canImplement(item: DisplayItem): boolean {
return item.id === implementPlanCandidateFromState(this.state)?.id;
}
async implement(item: DisplayItem): Promise<void> {
if (!this.canImplement(item)) return;
await this.host.ensureConnected();
if (!this.host.currentClient() || !this.state.activeThreadId) return;
this.host.stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
this.host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await this.host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}
private get state(): ChatState {
return this.host.stateStore.getState();
}
}
export function implementPlanCandidateFromState(
state: Pick<ChatState, "activeThreadId" | "turnLifecycle" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
): DisplayItem | null {
if (
!state.activeThreadId ||
chatTurnBusy(state) ||
state.composerDraft.trim().length > 0 ||
state.requestedCollaborationMode !== "plan"
) {
return null;
}
return (
[...state.displayItems].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ??
null
);
}

View file

@ -39,7 +39,7 @@ import {
statusSummaryLines as buildStatusSummaryLines,
toolbarViewModel as buildToolbarViewModel,
} from "./view-model";
import { composerSlotSnapshot, latestProposedPlanItem, openPanelTurnLifecycle, parseRestoredThreadState } from "./view-snapshot";
import { composerSlotSnapshot, openPanelTurnLifecycle, parseRestoredThreadState } from "./view-snapshot";
import {
ChatConnectionWorkTracker,
ChatResumeWorkTracker,
@ -58,6 +58,7 @@ import { ThreadIdentityController } from "./thread-identity-controller";
import { ThreadResumeController } from "./thread-resume-controller";
import { ChatViewRenderController } from "./view-render-controller";
import { ChatViewOpenCloseController } from "./view-open-close-controller";
import { PlanImplementationController } from "./plan-implementation-controller";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -103,6 +104,7 @@ export class CodexChatView extends ItemView {
private readonly turnSubmission: TurnSubmissionController;
private readonly slashCommands: SlashCommandController;
private readonly composerSubmission: ComposerSubmissionController;
private readonly planImplementation: PlanImplementationController;
private readonly connectionWork = new ChatConnectionWorkTracker();
private readonly resumeWork: ChatResumeWorkTracker;
private opened = false;
@ -212,7 +214,7 @@ export class CodexChatView extends ItemView {
consumeScrollIntent: () => this.messageScroll.consumeIntent(),
loadOlderTurns: () => void this.history.loadOlder(),
rollbackThread: (threadId) => void this.threadActions.rollbackThread(threadId),
implementPlan: (item) => void this.implementPlan(item),
implementPlan: (item) => void this.planImplementation.implement(item),
openTurnDiff: (state) => void this.plugin.openTurnDiff(state),
pendingRequestsSignature: () => this.pendingRequestsSignature(),
renderPendingRequests: () => this.pendingRequests.renderNode(),
@ -251,6 +253,12 @@ export class CodexChatView extends ItemView {
this.addSystemMessage(text);
},
});
this.planImplementation = new PlanImplementationController({
stateStore: this.chatState,
currentClient: () => this.client,
ensureConnected: () => this.ensureConnected(),
sendTurnText: (text) => this.turnSubmission.sendTurnText(text),
});
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, {
onNotification: (notification) => {
this.controller.handleNotification(notification);
@ -827,27 +835,10 @@ export class CodexChatView extends ItemView {
void this.app.workspace.requestSaveLayout();
}
private async implementPlan(item: DisplayItem): Promise<void> {
if (!this.canImplementPlanItem(item)) return;
await this.ensureConnected();
if (!this.client || !this.state.activeThreadId) return;
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
this.dispatch({ type: "ui/panel-set", panel: null });
await this.turnSubmission.sendTurnText("Please implement this plan.");
}
private async submitComposerAction(): Promise<void> {
await this.composerSubmission.submit();
}
private canImplementPlanItem(item: DisplayItem): boolean {
if (item.kind !== "message" || item.role !== "assistant" || item.proposedPlan !== true) return false;
if (!this.state.activeThreadId || this.turnBusy || this.state.composerDraft.trim().length > 0) return false;
if (this.state.requestedCollaborationMode !== "plan") return false;
return latestProposedPlanItem(this.state.displayItems)?.id === item.id;
}
private async setRequestedModelFromUi(model: string | null): Promise<void> {
await this.runtimeSettings.setRequestedModelFromUi(model);
}

View file

@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/client";
import { createChatState, createChatStateStore, type ChatStateStore } from "../../../src/features/chat/chat-state";
import {
implementPlanCandidateFromState,
PlanImplementationController,
type PlanImplementationControllerHost,
} from "../../../src/features/chat/plan-implementation-controller";
import type { DisplayItem } from "../../../src/features/chat/display/types";
const planItem = (id: string): DisplayItem => ({
id,
kind: "message",
role: "assistant",
text: "Plan",
proposedPlan: true,
});
function resumeThread(stateStore: ChatStateStore, displayItems: readonly DisplayItem[]): void {
stateStore.dispatch({
type: "thread/resumed",
thread: { id: "thread", cliVersion: "test" } as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalPolicy: null,
approvalsReviewer: null,
activePermissionProfile: null,
displayItems,
});
stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" });
}
function createController({ client = {} as AppServerClient } = {}) {
const stateStore = createChatStateStore(createChatState());
const host: PlanImplementationControllerHost = {
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
sendTurnText: vi.fn().mockResolvedValue(undefined),
};
return { controller: new PlanImplementationController(host), host, stateStore };
}
describe("PlanImplementationController", () => {
it("finds the latest proposed plan only when the thread is implementable", () => {
const stateStore = createChatStateStore(createChatState());
const first = planItem("first");
const latest = planItem("latest");
resumeThread(stateStore, [first, latest]);
expect(implementPlanCandidateFromState(stateStore.getState())).toBe(latest);
stateStore.dispatch({ type: "composer/draft-set", draft: "edit first" });
expect(implementPlanCandidateFromState(stateStore.getState())).toBeNull();
});
it("switches out of plan mode and submits the implementation prompt", async () => {
const { controller, host, stateStore } = createController();
const plan = planItem("plan");
resumeThread(stateStore, [plan]);
stateStore.dispatch({ type: "ui/panel-set", panel: "model" });
await controller.implement(plan);
expect(host.ensureConnected).toHaveBeenCalledOnce();
expect(stateStore.getState().requestedCollaborationMode).toBe("default");
expect(stateStore.getState().runtimePicker).toBeNull();
expect(host.sendTurnText).toHaveBeenCalledWith("Please implement this plan.");
});
it("ignores stale plan items", async () => {
const { controller, host, stateStore } = createController();
const first = planItem("first");
const latest = planItem("latest");
resumeThread(stateStore, [first, latest]);
await controller.implement(first);
expect(host.ensureConnected).not.toHaveBeenCalled();
expect(host.sendTurnText).not.toHaveBeenCalled();
});
});