diff --git a/src/App.tsx b/src/App.tsx
index 239a968e9..fc630a9ac 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -93,6 +93,7 @@ import { usePersistComposerSettings } from "./features/app/hooks/usePersistCompo
import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath";
import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController";
import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents";
+import { usePlanReadyActions } from "./features/app/hooks/usePlanReadyActions";
import { useWorkspaceActions } from "./features/app/hooks/useWorkspaceActions";
import { useWorkspaceCycling } from "./features/app/hooks/useWorkspaceCycling";
import { useThreadRows } from "./features/app/hooks/useThreadRows";
@@ -1711,6 +1712,17 @@ function MainApp() {
],
);
+ const { handlePlanAccept, handlePlanSubmitChanges } = usePlanReadyActions({
+ activeWorkspace,
+ activeThreadId,
+ collaborationModes,
+ resolvedModel,
+ resolvedEffort,
+ connectWorkspace,
+ sendUserMessageToThread,
+ setSelectedCollaborationModeId,
+ });
+
const orderValue = (entry: WorkspaceInfo) =>
typeof entry.settings.sortOrder === "number"
? entry.settings.sortOrder
@@ -1880,6 +1892,8 @@ function MainApp() {
handleApprovalDecision,
handleApprovalRemember,
handleUserInputSubmit,
+ onPlanAccept: handlePlanAccept,
+ onPlanSubmitChanges: handlePlanSubmitChanges,
onOpenSettings: () => openSettings(),
onOpenDictationSettings: () => openSettings("dictation"),
onOpenDebug: handleDebugClick,
diff --git a/src/features/app/components/PlanReadyFollowupMessage.tsx b/src/features/app/components/PlanReadyFollowupMessage.tsx
new file mode 100644
index 000000000..7e5d78f8d
--- /dev/null
+++ b/src/features/app/components/PlanReadyFollowupMessage.tsx
@@ -0,0 +1,61 @@
+import { useMemo, useState } from "react";
+
+type PlanReadyFollowupMessageProps = {
+ onAccept: () => void;
+ onSubmitChanges: (changes: string) => void;
+};
+
+export function PlanReadyFollowupMessage({
+ onAccept,
+ onSubmitChanges,
+}: PlanReadyFollowupMessageProps) {
+ const [changes, setChanges] = useState("");
+ const trimmed = useMemo(() => changes.trim(), [changes]);
+
+ return (
+
+
+
+
+
+
+ Start building from this plan, or describe changes to the plan.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/app/hooks/usePlanReadyActions.ts b/src/features/app/hooks/usePlanReadyActions.ts
new file mode 100644
index 000000000..e9c028ed5
--- /dev/null
+++ b/src/features/app/hooks/usePlanReadyActions.ts
@@ -0,0 +1,165 @@
+import { useCallback } from "react";
+import type { CollaborationModeOption, WorkspaceInfo } from "../../../types";
+import {
+ makePlanReadyAcceptMessage,
+ makePlanReadyChangesMessage,
+} from "../../../utils/internalPlanReadyMessages";
+
+type SendUserMessageOptions = {
+ collaborationMode?: Record | null;
+};
+
+type SendUserMessageToThread = (
+ workspace: WorkspaceInfo,
+ threadId: string,
+ message: string,
+ imageIds: string[],
+ options?: SendUserMessageOptions,
+) => Promise;
+
+type UsePlanReadyActionsOptions = {
+ activeWorkspace: WorkspaceInfo | null;
+ activeThreadId: string | null;
+ collaborationModes: CollaborationModeOption[];
+ resolvedModel: string | null;
+ resolvedEffort: string | null;
+ connectWorkspace: (workspace: WorkspaceInfo) => Promise;
+ sendUserMessageToThread: SendUserMessageToThread;
+ setSelectedCollaborationModeId: (modeId: string) => void;
+};
+
+export function usePlanReadyActions({
+ activeWorkspace,
+ activeThreadId,
+ collaborationModes,
+ resolvedModel,
+ resolvedEffort,
+ connectWorkspace,
+ sendUserMessageToThread,
+ setSelectedCollaborationModeId,
+}: UsePlanReadyActionsOptions) {
+ const findCollaborationMode = useCallback(
+ (wanted: string) => {
+ const normalized = wanted.trim().toLowerCase();
+ if (!normalized) {
+ return null;
+ }
+ return (
+ collaborationModes.find(
+ (mode) => mode.id.trim().toLowerCase() === normalized,
+ ) ??
+ collaborationModes.find(
+ (mode) => (mode.mode || mode.id).trim().toLowerCase() === normalized,
+ ) ??
+ null
+ );
+ },
+ [collaborationModes],
+ );
+
+ const buildCollaborationModePayloadFor = useCallback(
+ (mode: CollaborationModeOption | null) => {
+ if (!mode) {
+ return null;
+ }
+
+ const modeValue = mode.mode || mode.id;
+ if (!modeValue) {
+ return null;
+ }
+
+ const settings: Record = {
+ developer_instructions: mode.developerInstructions ?? null,
+ };
+
+ if (resolvedModel) {
+ settings.model = resolvedModel;
+ }
+ if (resolvedEffort !== null) {
+ settings.reasoning_effort = resolvedEffort;
+ }
+
+ return { mode: modeValue, settings };
+ },
+ [resolvedEffort, resolvedModel],
+ );
+
+ const handlePlanAccept = useCallback(async () => {
+ if (!activeWorkspace || !activeThreadId) {
+ return;
+ }
+
+ if (!activeWorkspace.connected) {
+ await connectWorkspace(activeWorkspace);
+ }
+
+ const defaultMode =
+ findCollaborationMode("default") ??
+ findCollaborationMode("code") ??
+ collaborationModes[0] ??
+ null;
+
+ if (defaultMode?.id) {
+ setSelectedCollaborationModeId(defaultMode.id);
+ }
+
+ const collaborationMode = buildCollaborationModePayloadFor(defaultMode);
+ await sendUserMessageToThread(
+ activeWorkspace,
+ activeThreadId,
+ makePlanReadyAcceptMessage(),
+ [],
+ collaborationMode ? { collaborationMode } : undefined,
+ );
+ }, [
+ activeThreadId,
+ activeWorkspace,
+ buildCollaborationModePayloadFor,
+ collaborationModes,
+ connectWorkspace,
+ findCollaborationMode,
+ sendUserMessageToThread,
+ setSelectedCollaborationModeId,
+ ]);
+
+ const handlePlanSubmitChanges = useCallback(
+ async (changes: string) => {
+ const trimmed = changes.trim();
+ if (!activeWorkspace || !activeThreadId || !trimmed) {
+ return;
+ }
+
+ if (!activeWorkspace.connected) {
+ await connectWorkspace(activeWorkspace);
+ }
+
+ const planMode = findCollaborationMode("plan");
+ if (planMode?.id) {
+ setSelectedCollaborationModeId(planMode.id);
+ }
+ const collaborationMode = buildCollaborationModePayloadFor(planMode);
+ const message = makePlanReadyChangesMessage(trimmed);
+ await sendUserMessageToThread(
+ activeWorkspace,
+ activeThreadId,
+ message,
+ [],
+ collaborationMode ? { collaborationMode } : undefined,
+ );
+ },
+ [
+ activeThreadId,
+ activeWorkspace,
+ buildCollaborationModePayloadFor,
+ connectWorkspace,
+ findCollaborationMode,
+ sendUserMessageToThread,
+ setSelectedCollaborationModeId,
+ ],
+ );
+
+ return {
+ handlePlanAccept,
+ handlePlanSubmitChanges,
+ };
+}
diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx
index 1731ae1f1..115fca4f1 100644
--- a/src/features/layout/hooks/useLayoutNodes.tsx
+++ b/src/features/layout/hooks/useLayoutNodes.tsx
@@ -145,6 +145,8 @@ type LayoutNodesOptions = {
request: RequestUserInputRequest,
response: RequestUserInputResponse,
) => void;
+ onPlanAccept?: () => void;
+ onPlanSubmitChanges?: (changes: string) => void;
onOpenSettings: () => void;
onOpenDictationSettings?: () => void;
onOpenDebug: () => void;
@@ -549,6 +551,8 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult {
showMessageFilePath={options.showMessageFilePath}
userInputRequests={options.userInputRequests}
onUserInputSubmit={options.handleUserInputSubmit}
+ onPlanAccept={options.onPlanAccept}
+ onPlanSubmitChanges={options.onPlanSubmitChanges}
onOpenThreadLink={options.onOpenThreadLink}
isThinking={options.isProcessing}
isLoadingMessages={
diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx
index 10efa5954..f06ce7eb5 100644
--- a/src/features/messages/components/Messages.test.tsx
+++ b/src/features/messages/components/Messages.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { useCallback, useState } from "react";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConversationItem } from "../../../types";
import { Messages } from "./Messages";
@@ -29,6 +29,10 @@ describe("Messages", () => {
}
});
+ afterEach(() => {
+ cleanup();
+ });
+
beforeEach(() => {
useFileLinkOpenerMock.mockClear();
openFileLinkMock.mockReset();
@@ -800,4 +804,297 @@ describe("Messages", () => {
expect(scrollNode.scrollTop).toBe(900);
});
+
+ it("shows a plan-ready follow-up prompt after a completed plan tool item", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-1",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "- Step 1",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Plan ready")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Implement this plan" }),
+ ).toBeTruthy();
+ });
+
+ it("hides the plan-ready follow-up once the user has replied after the plan", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-2",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "Plan text",
+ },
+ {
+ id: "user-after-plan",
+ kind: "message",
+ role: "user",
+ text: "OK",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
+
+ it("hides the plan-ready follow-up when the plan tool item is still running", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-3",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "Generating plan...",
+ status: "in_progress",
+ output: "Partial plan",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
+
+ it("shows the plan-ready follow-up once the turn stops thinking even if the plan status stays in_progress", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-stuck-in-progress",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "Generating plan...",
+ status: "in_progress",
+ output: "Plan text",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Plan ready")).toBeTruthy();
+ });
+
+ it("calls the plan follow-up callbacks", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-4",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "Plan text",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const sendChangesButton = screen.getByRole("button", { name: "Send changes" });
+ expect((sendChangesButton as HTMLButtonElement).disabled).toBe(true);
+
+ const textarea = screen.getByPlaceholderText(
+ "Describe what you want to change in the plan...",
+ );
+ fireEvent.change(textarea, { target: { value: "Add error handling" } });
+
+ expect((sendChangesButton as HTMLButtonElement).disabled).toBe(false);
+ fireEvent.click(sendChangesButton);
+ expect(onPlanSubmitChanges).toHaveBeenCalledWith("Add error handling");
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
+
+ it("dismisses the plan-ready follow-up when the plan is accepted", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-accept",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "Plan text",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Implement this plan" }),
+ );
+ expect(onPlanAccept).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
+
+ it("does not render plan-ready tagged internal user messages", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-6",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "Plan text",
+ },
+ {
+ id: "internal-user",
+ kind: "message",
+ role: "user",
+ text: "[[cm_plan_ready:accept]] Implement this plan.",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByText(/cm_plan_ready/)).toBeNull();
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
+
+ it("hides the plan follow-up when an input-requested bubble is active", () => {
+ const onPlanAccept = vi.fn();
+ const onPlanSubmitChanges = vi.fn();
+ const items: ConversationItem[] = [
+ {
+ id: "plan-5",
+ kind: "tool",
+ toolType: "plan",
+ title: "Plan",
+ detail: "completed",
+ status: "completed",
+ output: "Plan text",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Input requested")).toBeTruthy();
+ expect(screen.queryByText("Plan ready")).toBeNull();
+ });
});
diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx
index c27ebeb19..467f0ed5f 100644
--- a/src/features/messages/components/Messages.tsx
+++ b/src/features/messages/components/Messages.tsx
@@ -32,8 +32,10 @@ import type {
import { Markdown } from "./Markdown";
import { DiffBlock } from "../../git/components/DiffBlock";
import { languageFromPath } from "../../../utils/syntax";
+import { isPlanReadyTaggedMessage } from "../../../utils/internalPlanReadyMessages";
import { useFileLinkOpener } from "../hooks/useFileLinkOpener";
import { RequestUserInputMessage } from "../../app/components/RequestUserInputMessage";
+import { PlanReadyFollowupMessage } from "../../app/components/PlanReadyFollowupMessage";
type MessagesProps = {
items: ConversationItem[];
@@ -53,6 +55,8 @@ type MessagesProps = {
request: RequestUserInputRequest,
response: RequestUserInputResponse,
) => void;
+ onPlanAccept?: () => void;
+ onPlanSubmitChanges?: (changes: string) => void;
onOpenThreadLink?: (threadId: string) => void;
};
@@ -1143,6 +1147,8 @@ export const Messages = memo(function Messages({
showMessageFilePath = true,
userInputRequests = [],
onUserInputSubmit,
+ onPlanAccept,
+ onPlanSubmitChanges,
onOpenThreadLink,
}: MessagesProps) {
const bottomRef = useRef(null);
@@ -1255,6 +1261,13 @@ export const Messages = memo(function Messages({
const visibleItems = useMemo(
() =>
items.filter((item) => {
+ if (
+ item.kind === "message" &&
+ item.role === "user" &&
+ isPlanReadyTaggedMessage(item.text)
+ ) {
+ return false;
+ }
if (item.kind !== "reasoning") {
return true;
}
@@ -1331,6 +1344,7 @@ export const Messages = memo(function Messages({
const groupedItems = useMemo(() => buildToolGroups(visibleItems), [visibleItems]);
const hasActiveUserInputRequest = activeUserInputRequestId !== null;
+ const hasVisibleUserInputRequest = hasActiveUserInputRequest && Boolean(onUserInputSubmit);
const userInputNode =
hasActiveUserInputRequest && onUserInputSubmit ? (
) : null;
+ const [dismissedPlanFollowupByThread, setDismissedPlanFollowupByThread] =
+ useState>({});
+
+ const planFollowup = useMemo(() => {
+ if (!threadId) {
+ return { shouldShow: false, planItemId: null as string | null };
+ }
+ if (!onPlanAccept || !onPlanSubmitChanges) {
+ return { shouldShow: false, planItemId: null as string | null };
+ }
+ if (hasVisibleUserInputRequest) {
+ return { shouldShow: false, planItemId: null as string | null };
+ }
+ let planIndex = -1;
+ let planItem: Extract | null = null;
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const item = items[index];
+ if (item.kind === "tool" && item.toolType === "plan") {
+ planIndex = index;
+ planItem = item;
+ break;
+ }
+ }
+ if (!planItem) {
+ return { shouldShow: false, planItemId: null as string | null };
+ }
+ const planItemId = planItem.id;
+ if (dismissedPlanFollowupByThread[threadId] === planItemId) {
+ return { shouldShow: false, planItemId };
+ }
+ if (!(planItem.output ?? "").trim()) {
+ return { shouldShow: false, planItemId };
+ }
+ const planTone = toolStatusTone(planItem, false);
+ if (planTone === "failed") {
+ return { shouldShow: false, planItemId };
+ }
+ // Some backends stream plan output deltas without a final status update. As
+ // soon as the turn stops thinking, treat the latest plan output as ready.
+ if (isThinking && planTone !== "completed") {
+ return { shouldShow: false, planItemId };
+ }
+ for (let index = planIndex + 1; index < items.length; index += 1) {
+ const item = items[index];
+ if (item.kind === "message" && item.role === "user") {
+ return { shouldShow: false, planItemId };
+ }
+ }
+ return { shouldShow: true, planItemId };
+ }, [
+ dismissedPlanFollowupByThread,
+ hasVisibleUserInputRequest,
+ isThinking,
+ items,
+ onPlanAccept,
+ onPlanSubmitChanges,
+ threadId,
+ ]);
+
+ const planFollowupNode =
+ planFollowup.shouldShow && onPlanAccept && onPlanSubmitChanges ? (
+ {
+ if (threadId && planFollowup.planItemId) {
+ setDismissedPlanFollowupByThread((prev) => ({
+ ...prev,
+ [threadId]: planFollowup.planItemId!,
+ }));
+ }
+ onPlanAccept();
+ }}
+ onSubmitChanges={(changes) => {
+ if (threadId && planFollowup.planItemId) {
+ setDismissedPlanFollowupByThread((prev) => ({
+ ...prev,
+ [threadId]: planFollowup.planItemId!,
+ }));
+ }
+ onPlanSubmitChanges(changes);
+ }}
+ />
+ ) : null;
+
const renderItem = (item: ConversationItem) => {
if (item.kind === "message") {
const isCopied = copiedMessageId === item.id;
@@ -1465,6 +1562,7 @@ export const Messages = memo(function Messages({
}
return renderItem(entry.item);
})}
+ {planFollowupNode}
{userInputNode}