diff --git a/apps/backend/config/workflows/plan-and-build.yml b/apps/backend/config/workflows/plan-and-build.yml
index 337f4bcbd1..6862737c4c 100644
--- a/apps/backend/config/workflows/plan-and-build.yml
+++ b/apps/backend/config/workflows/plan-and-build.yml
@@ -53,6 +53,8 @@ steps:
on_enter:
- type: enable_plan_mode
- type: auto_start_agent
+ on_exit:
+ - type: disable_plan_mode
- id: implementation
name: Implementation
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go
index be663c0061..847d679a87 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go
@@ -1416,6 +1416,22 @@ func (s *Service) clearSessionPlanMode(ctx context.Context, session *models.Task
s.setSessionPlanMode(ctx, session, false)
}
+// SetSessionPlanModeByID looks up the session and writes plan_mode in its metadata.
+// Skips passthrough sessions, which manage plan mode in the underlying CLI.
+// Public entry point for client-driven plan-mode toggles (e.g. the "Implement plan"
+// affordance) so the change is server-authoritative and survives page refresh.
+func (s *Service) SetSessionPlanModeByID(ctx context.Context, sessionID string, enabled bool) error {
+ session, err := s.repo.GetTaskSession(ctx, sessionID)
+ if err != nil {
+ return err
+ }
+ if s.agentManager.IsPassthroughSession(ctx, session.ID) {
+ return nil
+ }
+ s.setSessionPlanMode(ctx, session, enabled)
+ return nil
+}
+
// setSessionPlanMode sets or clears plan mode in session metadata.
// Uses targeted metadata update to avoid overwriting other session fields.
func (s *Service) setSessionPlanMode(ctx context.Context, session *models.TaskSession, enabled bool) {
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
index 9d39455390..0d9fe2fc6e 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
@@ -590,6 +590,69 @@ func TestProcessOnExit(t *testing.T) {
})
}
+func TestSetSessionPlanModeByID(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("clears plan mode when enabled=false", func(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "t1", "s1", "step1")
+ _ = repo.UpdateSessionMetadata(ctx, "s1", map[string]interface{}{"plan_mode": true})
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ if err := svc.SetSessionPlanModeByID(ctx, "s1", false); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ updated, _ := repo.GetTaskSession(ctx, "s1")
+ if pm, _ := updated.Metadata["plan_mode"].(bool); pm {
+ t.Error("expected plan_mode to be cleared")
+ }
+ })
+
+ t.Run("sets plan mode when enabled=true", func(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "t1", "s1", "step1")
+
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ if err := svc.SetSessionPlanModeByID(ctx, "s1", true); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ updated, _ := repo.GetTaskSession(ctx, "s1")
+ if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm {
+ t.Error("expected plan_mode to be true")
+ }
+ })
+
+ t.Run("no-op for passthrough session", func(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "t1", "s1", "step1")
+ _ = repo.UpdateSessionMetadata(ctx, "s1", map[string]interface{}{"plan_mode": true})
+
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
+
+ if err := svc.SetSessionPlanModeByID(ctx, "s1", false); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ updated, _ := repo.GetTaskSession(ctx, "s1")
+ if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm {
+ t.Error("expected plan_mode to remain true for passthrough session")
+ }
+ })
+
+ t.Run("propagates session lookup error", func(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ if err := svc.SetSessionPlanModeByID(ctx, "missing", false); err == nil {
+ t.Error("expected error for missing session")
+ }
+ })
+}
+
func TestProcessOnEnterPassthrough(t *testing.T) {
ctx := context.Background()
diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go
index cbed2ecf6a..c74330178b 100644
--- a/apps/backend/internal/orchestrator/handlers/handlers.go
+++ b/apps/backend/internal/orchestrator/handlers/handlers.go
@@ -41,6 +41,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionSessionStop, h.wsStopSession)
d.RegisterFunc(ws.ActionSessionDelete, h.wsDeleteSession)
d.RegisterFunc(ws.ActionSessionSetPrimary, h.wsSetPrimarySession)
+ d.RegisterFunc(ws.ActionSessionSetPlanMode, h.wsSetPlanMode)
d.RegisterFunc(ws.ActionGitHubCheckSessionPR, h.wsCheckSessionPR)
}
@@ -147,6 +148,34 @@ func (h *Handlers) wsResetContext(ctx context.Context, msg *ws.Message) (*ws.Mes
})
}
+type wsSetPlanModeRequest struct {
+ SessionID string `json:"session_id"`
+ Enabled bool `json:"enabled"`
+}
+
+func (h *Handlers) wsSetPlanMode(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
+ var req wsSetPlanModeRequest
+ if err := msg.ParsePayload(&req); err != nil {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
+ }
+ if req.SessionID == "" {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
+ }
+
+ if err := h.service.SetSessionPlanModeByID(ctx, req.SessionID, req.Enabled); err != nil {
+ h.logger.Error("failed to set session plan mode",
+ zap.String("session_id", req.SessionID),
+ zap.Bool("enabled", req.Enabled),
+ zap.Error(err))
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to set plan mode: "+err.Error(), nil)
+ }
+ return ws.NewResponse(msg.ID, msg.Action, map[string]any{
+ "success": true,
+ "session_id": req.SessionID,
+ "enabled": req.Enabled,
+ })
+}
+
type wsRecoverSessionRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index 3023d40d0c..e817adc6c1 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -84,6 +84,7 @@ const (
ActionSessionStop = "session.stop"
ActionSessionDelete = "session.delete"
ActionSessionSetPrimary = "session.set_primary"
+ ActionSessionSetPlanMode = "session.set_plan_mode"
// Agent actions
ActionAgentList = "agent.list"
diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx
index e087a8e28e..93724ec8be 100644
--- a/apps/web/components/task/chat/chat-input-body.tsx
+++ b/apps/web/components/task/chat/chat-input-body.tsx
@@ -46,7 +46,7 @@ export type ChatInputEditorAreaProps = {
contextPopoverOpen: boolean;
setContextPopoverOpen: (open: boolean) => void;
contextFiles: ContextFile[];
- onImplementPlan?: () => void;
+ onImplementPlan?: (fresh: boolean) => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx
index e68d1ad2ad..54f88dd3e9 100644
--- a/apps/web/components/task/chat/chat-input-container.tsx
+++ b/apps/web/components/task/chat/chat-input-container.tsx
@@ -38,6 +38,7 @@ export type ChatInputContainerHandle = {
getSelectionStart: () => number;
insertText: (text: string, from: number, to: number) => void;
clear: () => void;
+ getAttachments: () => MessageAttachment[];
};
type ChatInputContainerProps = {
@@ -76,7 +77,7 @@ type ChatInputContainerProps = {
contextFiles?: ContextFile[];
onToggleContextFile?: (file: ContextFile) => void;
onAddContextFile?: (file: ContextFile) => void;
- onImplementPlan?: () => void;
+ onImplementPlan?: (fresh: boolean) => void;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
/** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */
diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx
index 37472f16ea..a9fc7d43b2 100644
--- a/apps/web/components/task/chat/chat-input-toolbar.tsx
+++ b/apps/web/components/task/chat/chat-input-toolbar.tsx
@@ -3,6 +3,7 @@
import { memo, useCallback, useRef, useState, type ReactNode } from "react";
import {
IconArrowUp,
+ IconChevronDown,
IconChevronsLeft,
IconDots,
IconFileTextSpark,
@@ -11,8 +12,15 @@ import {
IconPlugConnected,
IconPlugConnectedX,
IconPaperclip,
+ IconPlus,
IconRocket,
} from "@tabler/icons-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@kandev/ui/dropdown-menu";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
@@ -56,7 +64,7 @@ export type ChatInputToolbarProps = {
planContextEnabled?: boolean;
contextFiles?: ContextFile[];
onToggleFile?: (file: ContextFile) => void;
- onImplementPlan?: () => void;
+ onImplementPlan?: (fresh: boolean) => void;
/** Callback to enhance the current prompt with AI */
onEnhancePrompt?: () => void;
/** Whether prompt enhancement is in progress */
@@ -218,24 +226,59 @@ function PlanToggleButton({
);
}
-function ImplementPlanButton({ onClick }: { onClick: () => void }) {
- return (
+function ImplementPlanButton({ onClick }: { onClick: (fresh: boolean) => void }) {
+ const primary = (
- Implement the plan
+ Implement the plan in this session
);
+ return (
+
+ {primary}
+
+
+
+
+
+ onClick(true)}
+ className="cursor-pointer"
+ >
+
+
+
Implement in fresh agent
+
+ Starts a new session with a clean context window
+
+
+
+
+
+
+ );
}
function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
@@ -316,7 +359,7 @@ function ToolbarRightSection({
planModeEnabled: boolean;
isAgentBusy: boolean;
hasContent: boolean;
- onImplementPlan?: () => void;
+ onImplementPlan?: (fresh: boolean) => void;
isDisabled: boolean;
isSending: boolean;
onCancel: () => void;
diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts
index c216fc046b..0d68a8bd4a 100644
--- a/apps/web/components/task/chat/use-chat-input-container.ts
+++ b/apps/web/components/task/chat/use-chat-input-container.ts
@@ -40,6 +40,7 @@ type UseChatInputContainerParams = {
function useInputHandle(
ref: React.ForwardedRef,
inputRef: React.RefObject,
+ getAttachments: () => MessageAttachment[],
) {
useImperativeHandle(
ref,
@@ -52,8 +53,9 @@ function useInputHandle(
inputRef.current?.insertText(text, from, to);
},
clear: () => inputRef.current?.clear(),
+ getAttachments,
}),
- [inputRef],
+ [inputRef, getAttachments],
);
}
@@ -145,22 +147,22 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
getContentElement,
);
const fileInputRef = useRef(null);
- const { value, inputRef, addFiles, handleChange, handleSubmit, allItems } = useChatInputState({
- sessionId,
- isSending,
- contextItems,
- pendingCommentsByFile,
- hasContextComments: params.hasContextComments,
- showRequestChangesTooltip,
- onRequestChangesTooltipDismiss,
- onSubmit,
- });
+ const { value, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments } =
+ useChatInputState({
+ sessionId,
+ isSending,
+ contextItems,
+ pendingCommentsByFile,
+ hasContextComments: params.hasContextComments,
+ showRequestChangesTooltip,
+ onRequestChangesTooltipDismiss,
+ onSubmit,
+ });
useEffect(() => {
tiptapRef.current = inputRef.current;
});
-
- useInputHandle(ref, inputRef);
+ useInputHandle(ref, inputRef, getAttachments);
// Auto-expand the input container as the user types more lines
const handleChangeWithAutoExpand = useCallback(
@@ -175,12 +177,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
if (showRequestChangesTooltip && inputRef.current) inputRef.current.focus();
}, [showRequestChangesTooltip, inputRef]);
- const handleAgentCommand = useCallback(
- (commandName: string) => {
- onSubmit(`/${commandName}`);
- },
- [onSubmit],
- );
+ const handleAgentCommand = useCallback((cmd: string) => onSubmit(`/${cmd}`), [onSubmit]);
const handleSubmitWithReset = useCallback(
() => handleSubmit(resetHeight),
[handleSubmit, resetHeight],
diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts
index 0d98ec5bc8..4afcad844d 100644
--- a/apps/web/components/task/chat/use-chat-input-state.ts
+++ b/apps/web/components/task/chat/use-chat-input-state.ts
@@ -118,12 +118,18 @@ function useAttachments(sessionId: string | null) {
setAttachments((prev) => prev.filter((att) => att.id !== id));
}, []);
+ const getAttachments = useCallback(
+ () => toMessageAttachments(attachmentsRef.current),
+ [attachmentsRef],
+ );
+
return {
attachments,
attachmentsRef,
setAttachments,
addFiles,
handleRemoveAttachment,
+ getAttachments,
};
}
@@ -144,8 +150,14 @@ export function useChatInputState({
const pendingCommentsRef = useRef(pendingCommentsByFile);
const prevTextSessionIdRef = useRef(sessionId);
- const { attachments, attachmentsRef, setAttachments, addFiles, handleRemoveAttachment } =
- useAttachments(sessionId);
+ const {
+ attachments,
+ attachmentsRef,
+ setAttachments,
+ addFiles,
+ handleRemoveAttachment,
+ getAttachments,
+ } = useAttachments(sessionId);
// Reset text value from storage when session changes (runs before paint)
useLayoutEffect(() => {
@@ -158,10 +170,8 @@ export function useChatInputState({
useEffect(() => {
valueRef.current = value;
- }, [value]);
- useEffect(() => {
pendingCommentsRef.current = pendingCommentsByFile;
- }, [pendingCommentsByFile]);
+ }, [value, pendingCommentsByFile]);
const handleChange = useCallback(
(newValue: string) => {
@@ -223,5 +233,6 @@ export function useChatInputState({
return [...contextItems, ...attachmentItems];
}, [contextItems, attachments, handleRemoveAttachment]);
- return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems };
+ // prettier-ignore
+ return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments };
}
diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts
index b9a0e63ffc..9c7180f7d5 100644
--- a/apps/web/components/task/chat/use-chat-panel-state.ts
+++ b/apps/web/components/task/chat/use-chat-panel-state.ts
@@ -2,7 +2,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
-import { getLocalStorage } from "@/lib/local-storage";
import { useLayoutStore } from "@/lib/state/layout-store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { usePanelActions } from "@/hooks/use-panel-actions";
@@ -152,15 +151,6 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n
const planModeEnabled = planModeFromStore;
const planLayoutVisible = activeDocument?.type === "plan";
- useEffect(() => {
- if (!resolvedSessionId) return;
- const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false);
- if (stored) {
- setPlanMode(resolvedSessionId, true);
- addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" });
- }
- }, [resolvedSessionId, setPlanMode, addContextFile]);
-
useAutoApplyPlanLayout({
resolvedSessionId,
taskId,
diff --git a/apps/web/components/task/document/document-panel.tsx b/apps/web/components/task/document/document-panel.tsx
deleted file mode 100644
index c18ca6c774..0000000000
--- a/apps/web/components/task/document/document-panel.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-"use client";
-
-import { memo, useEffect, useMemo, useState, useCallback } from "react";
-import { IconX, IconChevronDown, IconPlayerPlay, IconPlus } from "@tabler/icons-react";
-import { TabsContent } from "@kandev/ui/tabs";
-import { Button } from "@kandev/ui/button";
-import { SessionPanel, SessionPanelContent } from "@kandev/ui/pannel-session";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@kandev/ui/dropdown-menu";
-import { useAppStore } from "@/components/state-provider";
-import { useLayoutStore } from "@/lib/state/layout-store";
-// closeDocument kept for mobile/tablet backward compat
-import { getWebSocketClient } from "@/lib/ws/connection";
-import { useTask } from "@/hooks/use-task";
-import { SessionTabs, type SessionTab } from "@/components/session-tabs";
-import { TaskPlanPanel } from "../task-plan-panel";
-import { TaskCreateDialog } from "@/components/task-create-dialog";
-import type { ActiveDocument } from "@/lib/state/slices/ui/types";
-
-type DocumentPanelProps = {
- sessionId: string | null;
-};
-
-export const DocumentPanel = memo(function DocumentPanel({ sessionId }: DocumentPanelProps) {
- const activeDocument = useAppStore((state) =>
- sessionId ? (state.documentPanel.activeDocumentBySessionId[sessionId] ?? null) : null,
- );
- const setActiveDocument = useAppStore((state) => state.setActiveDocument);
- const closeDocument = useLayoutStore((state) => state.closeDocument);
-
- const handleClose = () => {
- if (!sessionId) return;
- closeDocument(sessionId);
- setActiveDocument(sessionId, null);
- };
-
- // Auto-close document layout when no document is active (e.g. stale localStorage)
- useEffect(() => {
- if (!activeDocument && sessionId) {
- closeDocument(sessionId);
- }
- }, [activeDocument, sessionId, closeDocument]);
-
- const tabs: SessionTab[] = useMemo(() => {
- if (!activeDocument) return [];
- if (activeDocument.type === "plan") {
- return [{ id: "plan", label: "Plan" }];
- }
- return [{ id: "file", label: activeDocument.name }];
- }, [activeDocument]);
-
- const activeTab = activeDocument?.type === "plan" ? "plan" : "file";
-
- if (!activeDocument) {
- return null;
- }
-
- const isPlan = activeDocument.type === "plan";
-
- const headerActions = (
-
- {isPlan && }
-
-
-
-
- Close document panel
-
-
- );
-
- return (
-
- {}}
- className="flex-1 min-h-0 flex flex-col gap-2"
- rightContent={headerActions}
- >
-
-
-
-
-
-
-
-
- );
-});
-
-/** Dropdown button for implementing the plan */
-const ImplementPlanDropdown = memo(function ImplementPlanDropdown({
- sessionId,
- onClose,
-}: {
- sessionId: string | null;
- onClose: () => void;
-}) {
- const taskId = useAppStore((state) => state.tasks.activeTaskId);
- const task = useTask(taskId);
- const plan = useAppStore((state) => (taskId ? (state.taskPlans.byTaskId[taskId] ?? null) : null));
- const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
-
- const handleImplement = useCallback(async () => {
- if (!taskId || !sessionId || !plan?.content) return;
- const client = getWebSocketClient();
- if (!client) return;
-
- // Close plan mode first
- onClose();
-
- // Send the plan as an implementation prompt (not in plan mode)
- try {
- await client.request(
- "message.add",
- {
- task_id: taskId,
- session_id: sessionId,
- content: `Implement the following plan:\n\n${plan.content}`,
- },
- 10000,
- );
- } catch (err) {
- console.error("Failed to send implement plan message:", err);
- }
- }, [taskId, sessionId, plan, onClose]);
-
- const handleImplementNewSession = useCallback(() => {
- // Close plan mode and open the new session dialog
- onClose();
- setShowNewSessionDialog(true);
- }, [onClose]);
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
Implement in a new session
-
- Starts fresh with a clean context window
-
-
-
-
-
-
-
-
- >
- );
-});
-
-const DocumentPlanContent = memo(function DocumentPlanContent() {
- const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
- return ;
-});
-
-const DocumentFileContent = memo(function DocumentFileContent({ doc }: { doc: ActiveDocument }) {
- if (doc.type === "plan") return null;
-
- return (
-
-
- File viewer: {doc.path}
-
-
- );
-});
diff --git a/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts b/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts
new file mode 100644
index 0000000000..fb3fc38201
--- /dev/null
+++ b/apps/web/e2e/tests/chat/implement-plan-fresh.spec.ts
@@ -0,0 +1,148 @@
+import { type Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+
+// ---------------------------------------------------------------------------
+// "Implement plan" split-button — fresh-agent path
+// ---------------------------------------------------------------------------
+//
+// The toolbar shows the violet 🚀 Implement button only while plan mode is
+// active and the workflow's next step is not a work step. The split-button has
+// two paths:
+// - Primary click → same-session message.add (existing behavior)
+// - Chevron menu → "Implement in fresh agent" → launchSession(intent: start)
+// which spawns a brand-new TaskSession on the same task.
+//
+// These tests exercise the fresh-agent path end-to-end and assert the new
+// session inherits the planning session's agent profile.
+
+async function seedTaskAndEnterPlanMode(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+) {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
+
+ await session.togglePlanMode();
+ await expect(session.planModeInput()).toBeVisible({ timeout: 10_000 });
+
+ return { session, taskId: task.id, planningSessionId: task.session_id! };
+}
+
+test.describe("Implement plan — fresh-agent path", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("split-button menu launches a new session inheriting the planning session's agent profile", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const { session, taskId, planningSessionId } = await seedTaskAndEnterPlanMode(
+ testPage,
+ apiClient,
+ seedData,
+ "Implement plan fresh-agent test",
+ );
+
+ // Sanity: only the planning session exists at this point.
+ const before = await apiClient.listTaskSessions(taskId);
+ expect(before.sessions).toHaveLength(1);
+ expect(before.sessions[0].id).toBe(planningSessionId);
+ const planningProfileId = before.sessions[0].agent_profile_id;
+ expect(planningProfileId).toBeTruthy();
+
+ // Primary button is visible while plan mode is active.
+ const primary = testPage.getByTestId("implement-plan-button");
+ await expect(primary).toBeVisible({ timeout: 10_000 });
+
+ // Open the chevron dropdown and click the fresh-agent menu item.
+ await testPage.getByTestId("implement-plan-menu-trigger").click();
+ const freshItem = testPage.getByTestId("implement-fresh-menu-item");
+ await expect(freshItem).toBeVisible({ timeout: 5_000 });
+ await freshItem.click();
+
+ // A second TaskSession should appear on the task. launchSession is async
+ // (WS round-trip + lifecycle prepare), so poll instead of asserting once.
+ await expect
+ .poll(
+ async () => {
+ const { sessions } = await apiClient.listTaskSessions(taskId);
+ return sessions.length;
+ },
+ { timeout: 30_000, message: "fresh session never appeared" },
+ )
+ .toBe(2);
+
+ const { sessions } = await apiClient.listTaskSessions(taskId);
+ const freshSession = sessions.find((s) => s.id !== planningSessionId);
+ expect(freshSession).toBeDefined();
+
+ // Fresh session inherits the planning session's agent profile (the
+ // fresh-agent path passes agent_profile_id from the planning session into
+ // launchSession — no profile picker is shown).
+ expect(freshSession!.agent_profile_id).toBe(planningProfileId);
+
+ // Planning session was not deleted/replaced — fresh-agent path leaves it
+ // running in parallel.
+ expect(sessions.some((s) => s.id === planningSessionId)).toBe(true);
+
+ // Touch `session` so the unused-binding lint stays clean even after we
+ // dropped the dockview-tab assertion (which depended on lazy tab mount).
+ expect(session).toBeDefined();
+ });
+
+ test("dropdown is hidden until plan mode is enabled", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(60_000);
+
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Implement button hidden without plan mode",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
+
+ // No plan mode → no Implement button and no chevron trigger.
+ await expect(testPage.getByTestId("implement-plan-button")).not.toBeVisible();
+ await expect(testPage.getByTestId("implement-plan-menu-trigger")).not.toBeVisible();
+
+ // Toggle on → both appear.
+ await session.togglePlanMode();
+ await expect(testPage.getByTestId("implement-plan-button")).toBeVisible({ timeout: 10_000 });
+ await expect(testPage.getByTestId("implement-plan-menu-trigger")).toBeVisible();
+ });
+});
diff --git a/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts b/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts
new file mode 100644
index 0000000000..ec693ef552
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts
@@ -0,0 +1,287 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import type { TaskSession } from "@/lib/types/http";
+import type { MessageAttachment } from "@/components/task/chat/chat-input-container";
+
+const mockLaunchSession = vi.fn();
+const mockSetChatDraftContent = vi.fn();
+const mockToast = vi.fn();
+const mockWsRequest = vi.fn();
+const mockSetActiveSession = vi.fn();
+
+let mockStoreState: {
+ taskSessions: { items: Record };
+ setActiveSession: typeof mockSetActiveSession;
+} = {
+ taskSessions: { items: {} },
+ setActiveSession: mockSetActiveSession,
+};
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState),
+}));
+
+vi.mock("@/components/toast-provider", () => ({
+ useToast: () => ({ toast: mockToast }),
+}));
+
+vi.mock("@/lib/services/session-launch-service", () => ({
+ launchSession: (...args: unknown[]) => mockLaunchSession(...args),
+}));
+
+vi.mock("@/lib/ws/connection", () => ({
+ getWebSocketClient: () => ({ request: mockWsRequest }),
+}));
+
+vi.mock("@/lib/local-storage", async (importOriginal) => {
+ const actual = (await importOriginal()) as Record;
+ return {
+ ...actual,
+ setChatDraftContent: (...args: unknown[]) => mockSetChatDraftContent(...args),
+ };
+});
+
+import { useImplementFresh } from "./use-implement-fresh";
+
+const TASK_ID = "task-1";
+const SESS_PLAN = "sess-plan";
+const SESS_FRESH = "sess-fresh";
+
+function makeSession(overrides: Partial = {}): TaskSession {
+ return {
+ id: SESS_PLAN,
+ task_id: TASK_ID,
+ state: "WAITING_FOR_INPUT",
+ agent_profile_id: "ap-1",
+ executor_id: "ex-1",
+ created_at: "",
+ updated_at: "",
+ ...overrides,
+ } as TaskSession;
+}
+
+function makeChatRef(opts: { value?: string; attachments?: MessageAttachment[] } = {}) {
+ const clear = vi.fn();
+ const ref = {
+ current: {
+ focusInput: vi.fn(),
+ getTextareaElement: () => null,
+ getValue: () => opts.value ?? "",
+ getSelectionStart: () => 0,
+ insertText: vi.fn(),
+ clear,
+ getAttachments: () => opts.attachments ?? [],
+ },
+ };
+ return { ref, clear };
+}
+
+function setup(session: TaskSession | undefined = makeSession()) {
+ vi.clearAllMocks();
+ mockLaunchSession.mockResolvedValue({
+ success: true,
+ task_id: TASK_ID,
+ session_id: SESS_FRESH,
+ state: "RUNNING",
+ });
+ mockWsRequest.mockResolvedValue({ success: true });
+ mockStoreState = {
+ taskSessions: { items: session ? { [session.id]: session } : {} },
+ setActiveSession: mockSetActiveSession,
+ };
+}
+
+describe("useImplementFresh", () => {
+ beforeEach(() => setup());
+
+ it("launches a fresh session inheriting agent + executor profile from the planning session", async () => {
+ const { ref } = makeChatRef({ value: "double-check the migration" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession).toHaveBeenCalledTimes(1);
+ expect(mockLaunchSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ task_id: TASK_ID,
+ intent: "start",
+ agent_profile_id: "ap-1",
+ executor_id: "ex-1",
+ plan_mode: false,
+ prompt: expect.stringContaining("double-check the migration"),
+ }),
+ );
+ expect(mockLaunchSession.mock.calls[0][0].prompt).toContain("");
+ });
+
+ it("forwards attachments when present", async () => {
+ const attachments: MessageAttachment[] = [
+ { type: "image", data: "base64data", mime_type: "image/png" },
+ ];
+ const { ref } = makeChatRef({ value: "look at this", attachments });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession.mock.calls[0][0].attachments).toEqual(attachments);
+ });
+
+ it("omits attachments key when none are present", async () => {
+ const { ref } = makeChatRef({ value: "just text" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession.mock.calls[0][0]).not.toHaveProperty("attachments");
+ });
+
+ it("marks the fresh session as primary via WS after launch", async () => {
+ const { ref } = makeChatRef({ value: "implement" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockWsRequest).toHaveBeenCalledWith(
+ "session.set_primary",
+ { session_id: SESS_FRESH },
+ 10000,
+ );
+ });
+
+ it("focuses the fresh session as active in the UI after launch", async () => {
+ const { ref } = makeChatRef({ value: "implement" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockSetActiveSession).toHaveBeenCalledWith(TASK_ID, SESS_FRESH);
+ });
+
+ it("clears composer + draft only on successful launch", async () => {
+ const { ref, clear } = makeChatRef({ value: "ship" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(clear).toHaveBeenCalledTimes(1);
+ expect(mockSetChatDraftContent).toHaveBeenCalledWith(SESS_PLAN, null);
+ });
+
+ it("preserves composer + draft when launch fails so user can retry", async () => {
+ mockLaunchSession.mockRejectedValueOnce(new Error("network down"));
+ const { ref, clear } = makeChatRef({ value: "important" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(clear).not.toHaveBeenCalled();
+ expect(mockSetChatDraftContent).not.toHaveBeenCalled();
+ });
+
+ it("shows an error toast when launch fails", async () => {
+ mockLaunchSession.mockRejectedValueOnce(new Error("timeout"));
+ const { ref } = makeChatRef({ value: "implement" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
+ });
+
+ it("continues on set_primary failure to avoid losing the launch", async () => {
+ mockWsRequest.mockRejectedValueOnce(new Error("WS unavailable"));
+ const { ref, clear } = makeChatRef({ value: "continue anyway" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ // Should still clear and set active even if set_primary fails
+ expect(clear).toHaveBeenCalledTimes(1);
+ expect(mockSetActiveSession).toHaveBeenCalledWith(TASK_ID, SESS_FRESH);
+ });
+});
+
+describe("useImplementFresh guards", () => {
+ beforeEach(() => setup());
+
+ it("no-ops when session ID is missing", async () => {
+ const { ref } = makeChatRef();
+ const { result } = renderHook(() => useImplementFresh(null, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession).not.toHaveBeenCalled();
+ });
+
+ it("no-ops when planning session has no agent profile", async () => {
+ setup(makeSession({ agent_profile_id: undefined }));
+ const { ref } = makeChatRef();
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession).not.toHaveBeenCalled();
+ });
+
+ it("launches without executor_id when planning session is missing one (backend resolves)", async () => {
+ setup(makeSession({ executor_id: undefined }));
+ const { ref } = makeChatRef({ value: "go" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession).toHaveBeenCalledTimes(1);
+ expect(mockLaunchSession.mock.calls[0][0]).not.toHaveProperty("executor_id");
+ });
+
+ it("does not clear composer when launchSession resolves without a session_id", async () => {
+ mockLaunchSession.mockResolvedValueOnce({ success: true, task_id: TASK_ID, state: "RUNNING" });
+ const { ref, clear } = makeChatRef({ value: "preserve me" });
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(clear).not.toHaveBeenCalled();
+ expect(mockSetChatDraftContent).not.toHaveBeenCalled();
+ expect(mockSetActiveSession).not.toHaveBeenCalled();
+ });
+
+ it("no-ops when planning session is not in the store", async () => {
+ mockStoreState = { taskSessions: { items: {} }, setActiveSession: mockSetActiveSession }; // Empty store
+ mockLaunchSession.mockClear();
+ const { ref } = makeChatRef();
+ const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref));
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(mockLaunchSession).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/hooks/domains/kanban/use-implement-fresh.ts b/apps/web/hooks/domains/kanban/use-implement-fresh.ts
new file mode 100644
index 0000000000..9951652d48
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-implement-fresh.ts
@@ -0,0 +1,84 @@
+import type React from "react";
+import { useCallback } from "react";
+import { useAppStore } from "@/components/state-provider";
+import { useToast } from "@/components/toast-provider";
+import { setChatDraftContent } from "@/lib/local-storage";
+import { launchSession } from "@/lib/services/session-launch-service";
+import { getWebSocketClient } from "@/lib/ws/connection";
+import { buildImplementPlanContent } from "./use-plan-actions";
+import type { ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
+
+async function setupFreshSession(newSessionId: string): Promise {
+ // Set the fresh session as primary
+ const client = getWebSocketClient();
+ if (client) {
+ try {
+ await client.request("session.set_primary", { session_id: newSessionId }, 10000);
+ } catch (err) {
+ console.error("Failed to set fresh session as primary:", err);
+ // Continue even if set_primary fails
+ }
+ }
+}
+
+/**
+ * Launches a brand-new agent session that starts implementing the task plan
+ * from a clean context window. Inherits agent + executor from the planning
+ * session so the user doesn't pick anything; planning session is left running
+ * in parallel. Reuses the same kandev-system block as the same-session
+ * "Implement plan" path — both rely on get_task_plan_kandev to load the plan,
+ * which is task-scoped.
+ *
+ * The newly created session is automatically set as primary and focused (active
+ * in the UI) so the user works with the fresh implementation context immediately.
+ *
+ * Context files from the planning session aren't forwarded — `launchSession`
+ * doesn't support context_files yet, and the @ mentions inside the chat text
+ * are already inlined as markdown in `userText`.
+ */
+export function useImplementFresh(
+ resolvedSessionId: string | null,
+ taskId: string | null,
+ chatInputRef: React.RefObject,
+) {
+ const planningSession = useAppStore((s) =>
+ resolvedSessionId ? s.taskSessions.items[resolvedSessionId] : undefined,
+ );
+ const setActiveSession = useAppStore((s) => s.setActiveSession);
+ const { toast } = useToast();
+
+ return useCallback(async () => {
+ if (!taskId || !resolvedSessionId || !planningSession?.agent_profile_id) {
+ return;
+ }
+
+ const userText = chatInputRef.current?.getValue() ?? "";
+ const attachments = chatInputRef.current?.getAttachments() ?? [];
+ const prompt = buildImplementPlanContent(userText);
+
+ try {
+ const response = await launchSession({
+ task_id: taskId,
+ intent: "start",
+ agent_profile_id: planningSession.agent_profile_id,
+ ...(planningSession.executor_id && { executor_id: planningSession.executor_id }),
+ prompt,
+ plan_mode: false,
+ ...(attachments.length > 0 && { attachments }),
+ });
+
+ const newSessionId = response.session_id;
+ if (!newSessionId) return;
+
+ await setupFreshSession(newSessionId);
+ setActiveSession(taskId, newSessionId);
+
+ // Clear composer + draft only when a fresh session was actually created.
+ chatInputRef.current?.clear();
+ setChatDraftContent(resolvedSessionId, null);
+ } catch (err) {
+ console.error("Failed to launch fresh implementation session:", err);
+ toast({ description: "Failed to start implementation session", variant: "error" });
+ }
+ }, [taskId, resolvedSessionId, planningSession, chatInputRef, setActiveSession, toast]);
+}
diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts
new file mode 100644
index 0000000000..5baf3d3d54
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { ContextFile } from "@/lib/state/context-files-store";
+
+const mockGetState = vi.fn();
+
+vi.mock("@/lib/state/context-files-store", () => ({
+ useContextFilesStore: { getState: () => mockGetState() },
+}));
+
+import { buildImplementPlanContent, readContextFilesMeta } from "./use-plan-actions";
+
+describe("buildImplementPlanContent", () => {
+ it("appends the kandev-system block to user text", () => {
+ const out = buildImplementPlanContent("ship it");
+ expect(out.startsWith("ship it\n\n")).toBe(true);
+ expect(out).toContain("");
+ expect(out).toContain("get_task_plan_kandev");
+ expect(out).toContain("");
+ });
+
+ it("trims whitespace from user text", () => {
+ const out = buildImplementPlanContent(" hello \n");
+ expect(out.startsWith("hello\n\n")).toBe(true);
+ });
+
+ it("uses default visible text when input is empty", () => {
+ expect(buildImplementPlanContent("")).toMatch(/^Implement the plan\n\n/);
+ expect(buildImplementPlanContent(" ")).toMatch(/^Implement the plan\n\n/);
+ });
+});
+
+function file(path: string, name: string, pinned?: boolean): ContextFile {
+ return { path, name, pinned };
+}
+
+describe("readContextFilesMeta", () => {
+ const sessionId = "sess-1";
+ const appFilePath = "src/app.ts";
+ const appFileName = "app.ts";
+
+ beforeEach(() => vi.clearAllMocks());
+
+ it("returns an empty array when the session has no context files", () => {
+ mockGetState.mockReturnValue({ filesBySessionId: {} });
+ expect(readContextFilesMeta(sessionId)).toEqual([]);
+ });
+
+ it("maps real files to {path, name} pairs", () => {
+ mockGetState.mockReturnValue({
+ filesBySessionId: {
+ [sessionId]: [file(appFilePath, appFileName), file("README.md", "README.md")],
+ },
+ });
+ expect(readContextFilesMeta(sessionId)).toEqual([
+ { path: appFilePath, name: appFileName },
+ { path: "README.md", name: "README.md" },
+ ]);
+ });
+
+ it("filters out the special plan:context path", () => {
+ mockGetState.mockReturnValue({
+ filesBySessionId: {
+ [sessionId]: [file("plan:context", "Plan"), file(appFilePath, appFileName)],
+ },
+ });
+ expect(readContextFilesMeta(sessionId)).toEqual([{ path: appFilePath, name: appFileName }]);
+ });
+
+ it("filters out prompt: prefixed paths", () => {
+ mockGetState.mockReturnValue({
+ filesBySessionId: {
+ [sessionId]: [file("prompt:my-prompt", "My Prompt"), file(appFilePath, appFileName)],
+ },
+ });
+ expect(readContextFilesMeta(sessionId)).toEqual([{ path: appFilePath, name: appFileName }]);
+ });
+});
diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts
index 64130200e2..fe34f7e1fb 100644
--- a/apps/web/hooks/domains/kanban/use-plan-actions.ts
+++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts
@@ -7,6 +7,7 @@ import { moveTask } from "@/lib/api/domains/kanban-api";
import { useContextFilesStore } from "@/lib/state/context-files-store";
import { useLayoutStore } from "@/lib/state/layout-store";
import { useDockviewStore } from "@/lib/state/dockview-store";
+import { useImplementFresh } from "./use-implement-fresh";
import type { ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
const PLAN_CONTEXT_PATH = "plan:context";
@@ -86,6 +87,27 @@ function useNextWorkflowStep(taskId: string | null) {
return { proceedStepName, nextStepIsWorkStep, proceed, isMoving };
}
+const IMPLEMENT_PLAN_SYSTEM_BLOCK = `
+IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now.
+Read the current plan using the get_task_plan_kandev MCP tool.
+Implement all changes described in the plan step by step.
+After completing the implementation, provide a summary of what was done.
+`;
+
+export function buildImplementPlanContent(userText: string): string {
+ const visibleText = userText.trim() || "Implement the plan";
+ return `${visibleText}\n\n${IMPLEMENT_PLAN_SYSTEM_BLOCK}`;
+}
+
+/** Reads context files for the session, dropping the special plan:context and prompt: paths
+ * that are only meaningful in-session and not as standalone file references. */
+export function readContextFilesMeta(sessionId: string): Array<{ path: string; name: string }> {
+ const files = useContextFilesStore.getState().filesBySessionId[sessionId] ?? [];
+ return files
+ .filter((f) => !f.path.startsWith("prompt:") && f.path !== PLAN_CONTEXT_PATH)
+ .map((f) => ({ path: f.path, name: f.name }));
+}
+
function useImplementPlan(
resolvedSessionId: string | null,
taskId: string | null,
@@ -99,18 +121,10 @@ function useImplementPlan(
if (!client) return;
const userText = chatInputRef.current?.getValue() ?? "";
- chatInputRef.current?.clear();
- if (resolvedSessionId) {
- setChatDraftContent(resolvedSessionId, null);
- }
+ const attachments = chatInputRef.current?.getAttachments() ?? [];
+ const contextFilesMeta = readContextFilesMeta(resolvedSessionId);
- const visibleText = userText.trim() || "Implement the plan";
- const content = `${visibleText}\n\n
-IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now.
-Read the current plan using the get_task_plan_kandev MCP tool.
-Implement all changes described in the plan step by step.
-After completing the implementation, provide a summary of what was done.
-`;
+ const content = buildImplementPlanContent(userText);
client
.request(
@@ -120,12 +134,28 @@ After completing the implementation, provide a summary of what was done.
session_id: resolvedSessionId,
content,
plan_mode: false,
+ ...(attachments.length > 0 && { attachments }),
+ ...(contextFilesMeta.length > 0 && { context_files: contextFilesMeta }),
},
- 10000,
+ attachments.length > 0 ? 30000 : 10000,
)
+ .then(() => {
+ // Exit plan mode + clear composer only on success so a failed send
+ // leaves the layout and input intact for retry.
+ handlePlanModeChange(false);
+ chatInputRef.current?.clear();
+ setChatDraftContent(resolvedSessionId, null);
+ // Authoritatively clear plan_mode in session metadata so a refresh
+ // mid-implementation cannot re-hydrate plan mode from the server.
+ // Run as a separate request with its own catch so a set_plan_mode
+ // failure doesn't masquerade as a message send failure.
+ client
+ .request("session.set_plan_mode", { session_id: resolvedSessionId, enabled: false }, 5000)
+ .catch((err: unknown) =>
+ console.error("Failed to clear plan mode after implement:", err),
+ );
+ })
.catch((err: unknown) => console.error("Failed to send implement plan message:", err));
-
- handlePlanModeChange(false);
}, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]);
}
@@ -167,6 +197,11 @@ export function usePlanActions(opts: {
opts.handlePlanModeChange,
opts.chatInputRef,
);
+ const handleImplementFresh = useImplementFresh(
+ opts.resolvedSessionId,
+ opts.taskId,
+ opts.chatInputRef,
+ );
const {
proceedStepName,
nextStepIsWorkStep,
@@ -185,7 +220,9 @@ export function usePlanActions(opts: {
rawProceed();
}, [planModeEnabled, disablePlanMode, rawProceed]);
- const implementPlanHandler =
- opts.planModeEnabled && !nextStepIsWorkStep ? handleImplementPlan : undefined;
+ const showImplement = opts.planModeEnabled && !nextStepIsWorkStep;
+ const implementPlanHandler = showImplement
+ ? (fresh: boolean) => (fresh ? handleImplementFresh() : handleImplementPlan())
+ : undefined;
return { implementPlanHandler, proceedStepName, proceed, isMoving };
}
diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts
index 0602cbd601..a5a6d23106 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.ts
@@ -545,7 +545,6 @@ export const createUISlice: StateCreator
set((draft) => {
draft.chatInput.planModeBySessionId[sessionId] = enabled;
- setLocalStorage(`plan-mode-${sessionId}`, enabled);
}),
setActiveDocument: (sessionId, doc) =>
set((draft) => {