[open-swe] feat: add accepted plan action component for planner interface (#335)

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* fix build and accepted-plan-component rendering

* CR

* improve component UI design

* overflow scroll on accepted plan component

* cr

* Update apps/web/src/components/v2/actions-renderer.tsx

* fix: styling

---------

Co-authored-by: open-swe-dev[bot] <open-swe-dev@users.noreply.github.com>
Co-authored-by: starmorph <dylan@starmorph.com>
Co-authored-by: bracesproul <braceasproul@gmail.com>
This commit is contained in:
open-swe-dev[bot] 2025-07-03 20:46:11 +00:00 • committed by GitHub
parent 5a8e6d0656
commit 19206f0c34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 352 additions and 15 deletions

View file

@ -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<GraphUpdate, "taskPlan"> & { 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,
}),
],
});
}

View file

@ -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 (
<Badge
variant="secondary"
className="border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
>
<Check className="h-3 w-3" />
Approved
</Badge>
);
};
const getPlanItemIcon = (item: PlanItem) => {
if (item.completed) {
return (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
<CheckCircle className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
return (
<div className="bg-muted flex h-6 w-6 items-center justify-center rounded-full">
<Circle className="text-muted-foreground h-3.5 w-3.5" />
</div>
);
};
const totalCount = planItems.length;
return (
<div
className={cn(
"group via-background to-background dark:via-background dark:to-background rounded-xl border bg-gradient-to-br from-emerald-50/50 transition-shadow dark:from-emerald-950/20",
!collapsed ? "shadow-sm hover:shadow-md" : "",
)}
>
{/* Header */}
<div
className={cn(
"relative flex items-center border-b bg-gradient-to-r from-emerald-50 to-emerald-50/50 p-4 backdrop-blur-sm dark:from-emerald-950/30 dark:to-emerald-950/10",
!collapsed ? "rounded-t-xl rounded-b-none" : "rounded-xl",
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500 shadow-md dark:bg-emerald-600">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground text-sm font-semibold">
{getStatusText()}
</h3>
{getStatusBadge()}
</div>
{totalCount > 0 && (
<p className="text-muted-foreground mt-1 text-xs">
{totalCount} steps
</p>
)}
</div>
<Button
aria-label={
collapsed ? "Expand plan details" : "Collapse plan details"
}
onClick={() => setCollapsed((c) => !c)}
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-emerald-100 dark:hover:bg-emerald-950/50"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
collapsed ? "rotate-0" : "rotate-180",
)}
/>
</Button>
</div>
{/* Content */}
<div
className={cn(
"transition-all duration-300 ease-in-out",
collapsed ? "hidden" : "flex",
)}
>
<div className="space-y-4 p-4">
{planTitle && (
<div className="bg-card/50 rounded-lg border p-3">
<h4 className="text-foreground mb-1 text-sm font-medium">
Plan Overview
</h4>
<p className="text-muted-foreground text-sm leading-relaxed">
{planTitle}
</p>
</div>
)}
{planItems.length > 0 && (
<div className="space-y-3">
<h4 className="text-foreground flex items-center gap-2 text-sm font-medium">
Execution Steps
<Badge
variant="outline"
className="text-xs"
>
{totalCount}
</Badge>
</h4>
<div className="space-y-3">
{planItems
.sort((a, b) => a.index - b.index)
.map((item, idx) => (
<div
key={item.index}
className={cn(
"group/item flex items-start gap-3 rounded-lg p-3 transition-colors duration-200",
item.completed
? "bg-emerald-50/50 dark:bg-emerald-950/20"
: "bg-muted/30 hover:bg-muted/50",
)}
>
<div className="mt-0.5 flex-shrink-0">
{getPlanItemIcon(item)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p
className={cn(
"text-sm leading-relaxed",
item.completed
? "text-foreground"
: "text-muted-foreground",
)}
>
{item.plan}
</p>
<Badge
variant="outline"
className={cn(
"flex-shrink-0 text-xs",
item.completed &&
"border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400",
)}
>
{idx + 1}
</Badge>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -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<State extends PlannerGraphState | GraphState>({
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<State extends PlannerGraphState | GraphState>({
}
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<State extends PlannerGraphState | GraphState>({
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 (
<div className="flex w-full flex-col gap-2">
@ -145,6 +215,14 @@ export function ActionsRenderer<State extends PlannerGraphState | GraphState>({
handleRegenerate={() => {}}
/>
))}
{acceptedPlanEvents.length > 0 &&
isAcceptedPlanEvents(acceptedPlanEvents) && (
<AcceptedPlanStep
planTitle={acceptedPlanEvents[0].data.planTitle}
planItems={acceptedPlanEvents[0].data.planItems}
interruptType={acceptedPlanEvents[0].data.interruptType}
/>
)}
</div>
);
}

View file

@ -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",