mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
[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:
parent
5a8e6d0656
commit
19206f0c34
4 changed files with 352 additions and 15 deletions
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
204
apps/web/src/components/gen-ui/accepted-plan-step.tsx
Normal file
204
apps/web/src/components/gen-ui/accepted-plan-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue