feat: extend implement plan button with fresh-agent path and server-side plan mode (#832)

* feat: extend implement plan button with fresh-agent path and server-side plan mode

- Split-button: primary click = same-session, chevron = fresh agent with clean context window
- Forward draft attachments and context files on implement (adds getAttachments() to handle)
- Authoritatively clear plan_mode via new session.set_plan_mode WS endpoint, not a message flag
- Add on_exit:disable_plan_mode to plan-and-build.yml plan step (mirrors feature-dev.yml)
- Drop localStorage plan-mode rehydration; server session metadata is the single source of truth
- Delete dead DocumentPanel component (no callers)
- Add SetSessionPlanModeByID on orchestrator Service; wire wsSetPlanMode handler

* test: fix store isolation in useImplementFresh test + extract duplicate strings

- Fix test isolation: reset store directly and clear mocks before rendering
- Extract sessionId, appFilePath, appFileName to constants to fix ESLint duplicate-string warnings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(e2e): cover implement-plan fresh-agent split-button path

Adds Playwright coverage for the new chevron menu on the violet Implement
button: asserts a brand-new TaskSession is launched on the same task with
the planning session's agent profile inherited, and that the planning
session is left running in parallel. Second spec verifies the split-button
is gated behind plan mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: defer plan-mode exit and surface error toast on implement failures

Move handlePlanModeChange(false) into the message.add success callback in
useImplementPlan so a failed send leaves the plan layout intact for retry.
Add error toast to useImplementFresh catch so launchSession failures are
visible to the user instead of silently logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: guard fresh-agent launch on planning session executor_id

TaskSession.executor_id is optional, so the fresh-agent path could call
launchSession with an undefined executor and silently inherit the
workspace default instead of the planning session's executor. Add the
field to the early-return guard so the hook no-ops when the planning
session is missing either an agent profile or an executor.

* feat: mark fresh-agent session as primary and focus it

When useImplementFresh launches a new implementation session, the fresh
session is now automatically:
1. Set as primary via session.set_primary WS endpoint
2. Focused/selected as the active session in the UI

This improves UX by switching the focus to the fresh implementation context
immediately rather than keeping the planning session active.

Addresses comment in PR #832 from carlosflorencio.

- Extract setupFreshSession helper to keep complexity under 15
- Add tests for primary session marking and UI focus
- Add test for graceful handling of set_primary failures
- Update JSDoc to document the new behavior
- Extract test constants to avoid duplicate string warnings

* fix(test): replace self-referential test constants

`const TASK_ID = TASK_ID;` (and SESS_PLAN, SESS_FRESH) caused a TDZ
ReferenceError at module load, so vitest reported `(0 test)` and none of
the new fresh-agent coverage actually ran. Replace with literal values
so the 13 tests in this file execute again.

* fix: drop strict executor_id guard and skip composer clear on missing session_id

Two related issues:

1. e2e regression — the planning session in the FE store does not always
   carry executor_id (the field is omitempty on the backend DTO and isn't
   populated for fresh task sessions in the WS push path). The earlier
   guard required it before calling launchSession, so the fresh-agent
   path silently no-op'd in real flows. Drop executor_id from the guard
   and only forward it when set; backend resolves it from the agent
   profile / workspace default when omitted.

2. PR review (Greptile P1) — when launchSession resolves without a
   session_id, the previous flow still cleared the composer and draft
   even though no fresh session was activated. Bail out before the
   clear so the user keeps their typed text and can retry.

Tests updated:
- replace the no-op-on-missing-executor test with one that asserts the
  hook still launches and omits the key from the payload
- add coverage for the no-session_id branch keeping the composer intact

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luan Martins
2026-05-10 17:30:24 +01:00
committed by GitHub
parent 5223a1cd17
commit 1066ac5b16
18 changed files with 846 additions and 274 deletions
@@ -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
@@ -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) {
@@ -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()
@@ -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"`
+1
View File
@@ -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"
@@ -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;
@@ -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) */
@@ -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 = (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 text-violet-400"
onClick={onClick}
data-testid="implement-plan-button"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 text-violet-400 rounded-r-none pr-1.5"
onClick={() => onClick(false)}
>
<IconRocket className="h-4 w-4" />
<span className="text-xs">Implement</span>
</Button>
</TooltipTrigger>
<TooltipContent>Implement the plan</TooltipContent>
<TooltipContent>Implement the plan in this session</TooltipContent>
</Tooltip>
);
return (
<div className="flex items-center">
{primary}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="implement-plan-menu-trigger"
aria-label="More implement options"
className="h-7 px-1 cursor-pointer hover:bg-muted/40 text-violet-400 rounded-l-none border-l border-violet-400/20"
>
<IconChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem
data-testid="implement-fresh-menu-item"
onClick={() => onClick(true)}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-2 shrink-0 self-start mt-0.5" />
<div>
<div>Implement in fresh agent</div>
<div className="text-[11px] text-muted-foreground font-normal">
Starts a new session with a clean context window
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
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;
@@ -40,6 +40,7 @@ type UseChatInputContainerParams = {
function useInputHandle(
ref: React.ForwardedRef<ChatInputContainerHandle>,
inputRef: React.RefObject<TipTapInputHandle | null>,
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<HTMLInputElement>(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],
@@ -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 };
}
@@ -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,
@@ -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 = (
<div className="flex items-center gap-0.5">
{isPlan && <ImplementPlanDropdown sessionId={sessionId} onClose={handleClose} />}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer" onClick={handleClose}>
<IconX className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Close document panel</TooltipContent>
</Tooltip>
</div>
);
return (
<SessionPanel borderSide="right" margin="right">
<SessionTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={() => {}}
className="flex-1 min-h-0 flex flex-col gap-2"
rightContent={headerActions}
>
<TabsContent value="plan" className="flex-1 min-h-0">
<DocumentPlanContent />
</TabsContent>
<TabsContent value="file" className="flex-1 min-h-0">
<DocumentFileContent doc={activeDocument} />
</TabsContent>
</SessionTabs>
</SessionPanel>
);
});
/** 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 (
<>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 cursor-pointer text-xs hover:bg-muted/40"
onClick={handleImplement}
disabled={!plan?.content}
>
<IconPlayerPlay className="h-3 w-3" />
Implement
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-5 p-0 cursor-pointer hover:bg-muted/40"
disabled={!plan?.content}
>
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={handleImplementNewSession} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-2 shrink-0 self-start mt-0.5" />
<div>
<div>Implement in a new session</div>
<div className="text-[11px] text-muted-foreground font-normal">
Starts fresh with a clean context window
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TaskCreateDialog
open={showNewSessionDialog}
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
workflowId={null}
defaultStepId={null}
steps={[]}
taskId={taskId}
initialValues={{
title: task?.title ?? "",
description: plan?.content ? `Implement the following plan:\n\n${plan.content}` : "",
}}
/>
</>
);
});
const DocumentPlanContent = memo(function DocumentPlanContent() {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
return <TaskPlanPanel taskId={activeTaskId} visible />;
});
const DocumentFileContent = memo(function DocumentFileContent({ doc }: { doc: ActiveDocument }) {
if (doc.type === "plan") return null;
return (
<SessionPanelContent>
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
File viewer: {doc.path}
</div>
</SessionPanelContent>
);
});
@@ -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();
});
});
@@ -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<string, TaskSession> };
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<string, unknown>;
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> = {}): 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("<kandev-system>");
});
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();
});
});
@@ -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<void> {
// 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<ChatInputContainerHandle | null>,
) {
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]);
}
@@ -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("<kandev-system>");
expect(out).toContain("get_task_plan_kandev");
expect(out).toContain("</kandev-system>");
});
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 }]);
});
});
@@ -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 = `<kandev-system>
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.
</kandev-system>`;
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<kandev-system>
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.
</kandev-system>`;
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 };
}
-1
View File
@@ -545,7 +545,6 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
setPlanMode: (sessionId, enabled) =>
set((draft) => {
draft.chatInput.planModeBySessionId[sessionId] = enabled;
setLocalStorage(`plan-mode-${sessionId}`, enabled);
}),
setActiveDocument: (sessionId, doc) =>
set((draft) => {