mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract plan implementation controller
This commit is contained in:
parent
92a4ed01bf
commit
640a411710
4 changed files with 149 additions and 35 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
51
src/features/chat/plan-implementation-controller.ts
Normal file
51
src/features/chat/plan-implementation-controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
86
tests/features/chat/plan-implementation-controller.test.ts
Normal file
86
tests/features/chat/plan-implementation-controller.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue