diff --git a/src/features/chat/chat-message-renderer.ts b/src/features/chat/chat-message-renderer.ts index ea6e015a..f1f2b47d 100644 --- a/src/features/chat/chat-message-renderer.ts +++ b/src/features/chat/chat-message-renderer.ts @@ -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, -): 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 }; diff --git a/src/features/chat/plan-implementation-controller.ts b/src/features/chat/plan-implementation-controller.ts new file mode 100644 index 00000000..db7f09cc --- /dev/null +++ b/src/features/chat/plan-implementation-controller.ts @@ -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; + sendTurnText: (text: string) => Promise; +} + +export class PlanImplementationController { + constructor(private readonly host: PlanImplementationControllerHost) {} + + canImplement(item: DisplayItem): boolean { + return item.id === implementPlanCandidateFromState(this.state)?.id; + } + + async implement(item: DisplayItem): Promise { + 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, +): 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 + ); +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 2ea6f59b..adc79c3e 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -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 { - 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 { 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 { await this.runtimeSettings.setRequestedModelFromUi(model); } diff --git a/tests/features/chat/plan-implementation-controller.test.ts b/tests/features/chat/plan-implementation-controller.test.ts new file mode 100644 index 00000000..12fc1143 --- /dev/null +++ b/tests/features/chat/plan-implementation-controller.test.ts @@ -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(); + }); +});