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) => {