diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 3906dfa2..2ea577b7 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -1,9 +1,11 @@ import { v4 as uuidv4 } from "uuid"; +import { AIMessage, BaseMessage } from "@langchain/core/messages"; import { Command, END, interrupt } from "@langchain/langgraph"; import { GraphUpdate, GraphConfig, TaskPlan, + PlanItem, } from "@open-swe/shared/open-swe/types"; import { ActionRequest, @@ -20,6 +22,7 @@ import { GITHUB_USER_LOGIN_HEADER, PLAN_INTERRUPT_ACTION_TITLE, PLAN_INTERRUPT_DELIMITER, + DO_NOT_RENDER_ID_PREFIX, PROGRAMMER_GRAPH_ID, } from "@open-swe/shared/constants"; import { @@ -29,15 +32,50 @@ import { import { createLangGraphClient } from "../../../utils/langgraph-client.js"; import { addTaskPlanToIssue } from "../../../utils/github/issue-task.js"; import { createLogger, LogLevel } from "../../../utils/logger.js"; +import { + ACCEPTED_PLAN_NODE_ID, + CustomNodeEvent, +} from "@open-swe/shared/open-swe/custom-node-events"; const logger = createLogger(LogLevel.INFO, "ProposedPlan"); +function createAcceptedPlanMessage(input: { + planTitle: string; + planItems: PlanItem[]; + interruptType: HumanResponse["type"]; +}) { + const { planTitle, planItems, interruptType } = input; + const acceptedPlanEvent: CustomNodeEvent = { + nodeId: ACCEPTED_PLAN_NODE_ID, + actionId: uuidv4(), + action: "Plan accepted", + createdAt: new Date().toISOString(), + data: { + status: "success", + planTitle, + planItems, + interruptType, + }, + }; + + const acceptedPlanMessage = new AIMessage({ + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + content: "", + additional_kwargs: { + hidden: true, + customNodeEvents: [acceptedPlanEvent], + }, + }); + return acceptedPlanMessage; +} + async function startProgrammerRun(input: { runInput: Exclude & { taskPlan: TaskPlan }; state: PlannerGraphState; config: GraphConfig; + newMessages?: BaseMessage[]; }) { - const { runInput, state, config } = input; + const { runInput, state, config, newMessages } = input; const langGraphClient = createLangGraphClient({ defaultHeaders: { [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", @@ -83,6 +121,7 @@ async function startProgrammerRun(input: { }, sandboxSessionId: runInput.sandboxSessionId, taskPlan: runInput.taskPlan, + messages: newMessages, }; } @@ -95,6 +134,7 @@ export async function interruptProposedPlan( throw new Error("No proposed plan found."); } + let planItems: PlanItem[]; const userRequest = getUserRequest(state.messages); const runInput: GraphUpdate = { contextGatheringNotes: state.contextGatheringNotes, @@ -105,7 +145,7 @@ export async function interruptProposedPlan( if (state.autoAcceptPlan) { logger.info("Auto accepting plan."); - const planItems = proposedPlan.map((p, index) => ({ + planItems = proposedPlan.map((p, index) => ({ index, plan: p, completed: false, @@ -123,6 +163,13 @@ export async function interruptProposedPlan( }, state, config, + newMessages: [ + createAcceptedPlanMessage({ + planTitle: state.proposedPlanTitle, + planItems, + interruptType: "accept", + }), + ], }); } @@ -161,7 +208,7 @@ export async function interruptProposedPlan( } if (interruptRes.type === "accept") { - const planItems = proposedPlan.map((p, index) => ({ + planItems = proposedPlan.map((p, index) => ({ index, plan: p, completed: false, @@ -178,7 +225,7 @@ export async function interruptProposedPlan( .split(PLAN_INTERRUPT_DELIMITER) .map((step: string) => step.trim()); - const planItems = editedPlan.map((p: string, index: number) => ({ + planItems = editedPlan.map((p: string, index: number) => ({ index, plan: p, completed: false, @@ -200,5 +247,12 @@ export async function interruptProposedPlan( }, state, config, + newMessages: [ + createAcceptedPlanMessage({ + planTitle: state.proposedPlanTitle, + planItems, + interruptType: interruptRes.type, + }), + ], }); } diff --git a/apps/web/src/components/gen-ui/accepted-plan-step.tsx b/apps/web/src/components/gen-ui/accepted-plan-step.tsx new file mode 100644 index 00000000..aed9f396 --- /dev/null +++ b/apps/web/src/components/gen-ui/accepted-plan-step.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + CheckCircle, + ChevronDown, + Sparkles, + Circle, + Check, +} from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { HumanResponse } from "@langchain/langgraph/prebuilt"; + +type PlanItem = { + index: number; + plan: string; + completed: boolean; +}; + +type AcceptedPlanStepProps = { + planTitle?: string; + planItems?: PlanItem[]; + interruptType?: HumanResponse["type"]; + collapse?: boolean; +}; + +export function AcceptedPlanStep({ + planTitle, + planItems = [], + interruptType, + collapse: collapseProp = true, +}: AcceptedPlanStepProps) { + const [collapsed, setCollapsed] = useState(collapseProp); + + const getStatusText = () => { + if (interruptType === "edit") { + return "Plan revised and approved"; + } + return "Plan approved and ready"; + }; + + const getStatusBadge = () => { + return ( + + + Approved + + ); + }; + + const getPlanItemIcon = (item: PlanItem) => { + if (item.completed) { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + }; + + const totalCount = planItems.length; + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+

+ {getStatusText()} +

+ {getStatusBadge()} +
+ {totalCount > 0 && ( +

+ {totalCount} steps +

+ )} +
+ + +
+ + {/* Content */} +
+
+ {planTitle && ( +
+

+ Plan Overview +

+

+ {planTitle} +

+
+ )} + + {planItems.length > 0 && ( +
+

+ Execution Steps + + {totalCount} + +

+ +
+ {planItems + .sort((a, b) => a.index - b.index) + .map((item, idx) => ( +
+
+ {getPlanItemIcon(item)} +
+ +
+
+

+ {item.plan} +

+ + {idx + 1} + +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/v2/actions-renderer.tsx b/apps/web/src/components/v2/actions-renderer.tsx index 336da898..55be3a7a 100644 --- a/apps/web/src/components/v2/actions-renderer.tsx +++ b/apps/web/src/components/v2/actions-renderer.tsx @@ -7,13 +7,56 @@ import { isCustomNodeEvent, CustomNodeEvent, INITIALIZE_NODE_ID, + ACCEPTED_PLAN_NODE_ID, mapCustomEventsToSteps, } from "@open-swe/shared/open-swe/custom-node-events"; -import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants"; +import { + DO_NOT_RENDER_ID_PREFIX, + PLANNER_GRAPH_ID, +} from "@open-swe/shared/constants"; import { Message } from "@langchain/langgraph-sdk"; import { InitializeStep } from "../gen-ui/initialize-step"; +import { AcceptedPlanStep } from "../gen-ui/accepted-plan-step"; import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; -import { GraphState } from "@open-swe/shared/open-swe/types"; +import { GraphState, PlanItem } from "@open-swe/shared/open-swe/types"; +import { HumanResponse } from "@langchain/langgraph/prebuilt"; + +interface AcceptedPlanEventData { + planTitle: string; + planItems: PlanItem[]; + interruptType: HumanResponse["type"]; +} + +type AcceptedPlanEvent = CustomNodeEvent & { + data: AcceptedPlanEventData; +}; + +function isAcceptedPlanEvent( + event: CustomNodeEvent, +): event is AcceptedPlanEvent { + const { data } = event; + return ( + typeof data === "object" && + data !== null && + typeof data.planTitle === "string" && + Array.isArray(data.planItems) && + data.planItems.every( + (item) => + typeof item === "object" && + item !== null && + typeof item.index === "number" && + typeof item.plan === "string" && + typeof item.completed === "boolean", + ) && + (data.interruptType === "accept" || data.interruptType === "edit") + ); +} + +function isAcceptedPlanEvents( + events: CustomNodeEvent[], +): events is AcceptedPlanEvent[] { + return events.every(isAcceptedPlanEvent); +} interface ActionsRendererProps { graphId: string; @@ -74,6 +117,11 @@ export function ActionsRenderer({ const initializeEvents = customNodeEvents.filter( (e) => e.nodeId === INITIALIZE_NODE_ID, ); + + const acceptedPlanEvents = customNodeEvents.filter( + (e) => e.nodeId === ACCEPTED_PLAN_NODE_ID, + ); + const steps = mapCustomEventsToSteps(initializeEvents); const allSuccess = steps.length > 0 && steps.every((s) => s.status === "success"); @@ -84,15 +132,29 @@ export function ActionsRenderer({ } useEffect(() => { - const customInitEvents = getCustomNodeEventsFromMessages( - stream.messages, - INITIALIZE_NODE_ID, - ); - // If there are no custom init events found in messages, or we already have steps from custom events, return - if (!customInitEvents?.length || initializeEvents.length) { + const allCustomEvents = getCustomNodeEventsFromMessages(stream.messages); + if (!allCustomEvents?.length) { return; } - setCustomNodeEvents(customInitEvents); + + setCustomNodeEvents((prev) => { + // If no existing events, set all new events + if (prev.length === 0) { + return allCustomEvents; + } + + // Merge new events with existing ones, avoiding duplicates + const existingActionIds = new Set(prev.map((e) => e.actionId)); + const newEvents = allCustomEvents.filter( + (e) => !existingActionIds.has(e.actionId), + ); + + if (newEvents.length > 0) { + return [...prev, ...newEvents]; + } + + return prev; + }); }, [stream.messages]); const streamJoined = useRef(false); @@ -122,9 +184,17 @@ export function ActionsRenderer({ programmerSession?.threadId) ) { setProgrammerSession?.(stream.values.programmerSession); - setSelectedTab?.("programmer"); + + // Only switch tabs from the planner ActionsRenderer to ensure proper timing + // This allows the accepted plan step to be visible before switching + if (graphId === PLANNER_GRAPH_ID) { + // Add a small delay to allow the accepted plan step to render first + setTimeout(() => { + setSelectedTab?.("programmer"); + }, 2000); + } } - }, [stream.values]); + }, [stream.values, graphId]); return (
@@ -145,6 +215,14 @@ export function ActionsRenderer({ handleRegenerate={() => {}} /> ))} + {acceptedPlanEvents.length > 0 && + isAcceptedPlanEvents(acceptedPlanEvents) && ( + + )}
); } diff --git a/packages/shared/src/open-swe/custom-node-events.ts b/packages/shared/src/open-swe/custom-node-events.ts index decb6fab..2d56f9dd 100644 --- a/packages/shared/src/open-swe/custom-node-events.ts +++ b/packages/shared/src/open-swe/custom-node-events.ts @@ -27,6 +27,7 @@ export function isCustomNodeEvent(event: unknown): event is CustomNodeEvent { ); } export const INITIALIZE_NODE_ID = "initialize"; +export const ACCEPTED_PLAN_NODE_ID = "accepted-plan"; export const INIT_STEPS = [ "Resuming sandbox",