diff --git a/apps/web/src/components/v2/actions-renderer.tsx b/apps/web/src/components/v2/actions-renderer.tsx index 55be3a7a..48a6fca5 100644 --- a/apps/web/src/components/v2/actions-renderer.tsx +++ b/apps/web/src/components/v2/actions-renderer.tsx @@ -3,6 +3,7 @@ import { UseStream, useStream } from "@langchain/langgraph-sdk/react"; import { AssistantMessage } from "../thread/messages/ai"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; +import { useCancelStream } from "@/hooks/useCancelStream"; import { isCustomNodeEvent, CustomNodeEvent, @@ -67,6 +68,7 @@ interface ActionsRendererProps { ) => void; programmerSession?: ManagerGraphState["programmerSession"]; setSelectedTab?: Dispatch>; + onStreamReady: (cancelFn: (() => void) | undefined) => void; } const getCustomNodeEventsFromMessages = ( @@ -98,6 +100,7 @@ export function ActionsRenderer({ setProgrammerSession, programmerSession, setSelectedTab, + onStreamReady, }: ActionsRendererProps) { const [customNodeEvents, setCustomNodeEvents] = useState( [], @@ -114,6 +117,13 @@ export function ActionsRenderer({ }, }); + const { cancelRun } = useCancelStream({ + stream, + threadId, + runId, + streamName: graphId === "planner" ? "Planner" : "Programmer", + }); + const initializeEvents = customNodeEvents.filter( (e) => e.nodeId === INITIALIZE_NODE_ID, ); @@ -167,6 +177,14 @@ export function ActionsRenderer({ } }, [runId]); + useEffect(() => { + if (stream.isLoading) { + onStreamReady(cancelRun); + } else { + onStreamReady(undefined); + } + }, [onStreamReady, runId]); // Depend on runId instead of cancelRun to avoid infinite loops + // Filter out human & do not render messages const filteredMessages = stream.messages?.filter( (m) => diff --git a/apps/web/src/components/v2/cancel-stream-button.tsx b/apps/web/src/components/v2/cancel-stream-button.tsx new file mode 100644 index 00000000..cbf166ff --- /dev/null +++ b/apps/web/src/components/v2/cancel-stream-button.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { UseStream } from "@langchain/langgraph-sdk/react"; +import { useCancelStream } from "@/hooks/useCancelStream"; + +interface CancelStreamButtonProps { + stream: UseStream; + threadId?: string; + runId?: string; + streamName: "Planner" | "Programmer"; +} + +export function CancelStreamButton({ + stream, + threadId, + runId, + streamName, +}: CancelStreamButtonProps) { + const { cancelRun } = useCancelStream({ + stream, + threadId, + runId, + streamName, + }); + + const shouldShow = stream.isLoading && threadId && runId; + + if (!shouldShow) { + return null; + } + + return ( + + ); +} diff --git a/apps/web/src/components/v2/manager-chat.tsx b/apps/web/src/components/v2/manager-chat.tsx index 31173bbf..a597dce4 100644 --- a/apps/web/src/components/v2/manager-chat.tsx +++ b/apps/web/src/components/v2/manager-chat.tsx @@ -5,10 +5,12 @@ import { useState } from "react"; import { StickToBottom } from "use-stick-to-bottom"; import { TooltipIconButton } from "../ui/tooltip-icon-button"; import { AnimatePresence, motion } from "framer-motion"; -import { Bot, Copy, CopyCheck, Send, User } from "lucide-react"; +import { Bot, Copy, CopyCheck, Send, User, Loader2 } from "lucide-react"; import { Textarea } from "../ui/textarea"; import { Button } from "../ui/button"; - +import { useStream } from "@langchain/langgraph-sdk/react"; +import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types"; +import { cn } from "@/lib/utils"; function MessageCopyButton({ content }: { content: string }) { const [copied, setCopied] = useState(false); @@ -61,6 +63,8 @@ interface ManagerChatProps { chatInput: string; setChatInput: (input: string) => void; handleSendMessage: () => void; + isLoading: boolean; + cancelRun: () => void; } export function ManagerChat({ @@ -68,6 +72,8 @@ export function ManagerChat({ chatInput, setChatInput, handleSendMessage, + isLoading, + cancelRun, }: ManagerChatProps) { return (
@@ -133,19 +139,27 @@ export function ManagerChat({ placeholder="Type your message..." className="border-border bg-background text-foreground placeholder:text-muted-foreground min-h-[60px] flex-1 resize-none text-sm dark:bg-gray-900" onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !isLoading) { e.preventDefault(); handleSendMessage(); } }} />
diff --git a/apps/web/src/components/v2/quick-actions.tsx b/apps/web/src/components/v2/quick-actions.tsx index 17fc342d..5f778ea4 100644 --- a/apps/web/src/components/v2/quick-actions.tsx +++ b/apps/web/src/components/v2/quick-actions.tsx @@ -24,6 +24,34 @@ The file should not contain any other content besides those tags, and the rules/ With all of this in mind, please explore the repository and write this single \`AGENTS.md\` file with the rules/context/instructions gathered!`; +const DEV_README_PROMPT = `Please add a new callout to the root readme in the repository: +"Welcome to Open SWE!" +Make it a 'tip' callout`; + +function DevReadmePromptQuickAction({ + setQuickActionPrompt, +}: QuickActionsProps) { + const nodeEnv = process.env.NODE_ENV; + if (nodeEnv === "production") { + return null; + } + return ( + setQuickActionPrompt(DEV_README_PROMPT)} + className="border-border bg-card hover:bg-muted cursor-pointer py-3 transition-shadow hover:shadow-lg dark:bg-gray-950" + > + + + [DEV] Add Welcome Callout + + + Add a welcome callout to the root readme. + + + + ); +} + interface QuickActionsProps { setQuickActionPrompt: Dispatch>; } @@ -35,6 +63,9 @@ export function QuickActions({ setQuickActionPrompt }: QuickActionsProps) { Quick Actions
+ setQuickActionPrompt(GENERATE_RULES_PROMPT)} className="border-border bg-card hover:bg-muted cursor-pointer py-3 transition-shadow hover:shadow-lg dark:bg-gray-950" diff --git a/apps/web/src/components/v2/thread-view.tsx b/apps/web/src/components/v2/thread-view.tsx index 49d4336e..c931c9e0 100644 --- a/apps/web/src/components/v2/thread-view.tsx +++ b/apps/web/src/components/v2/thread-view.tsx @@ -1,7 +1,7 @@ "use client"; import { v4 as uuidv4 } from "uuid"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { ArrowLeft, GitBranch, Terminal, Clock } from "lucide-react"; @@ -26,6 +26,7 @@ import { ScrollToBottom, } from "../../utils/scroll-utils"; import { ManagerChat } from "./manager-chat"; +import { CancelStreamButton } from "./cancel-stream-button"; interface ThreadViewProps { stream: ReturnType>; @@ -49,6 +50,14 @@ export function ThreadView({ const [programmerSession, setProgrammerSession] = useState(); + const plannerCancelRef = useRef<(() => void) | null>(null); + const programmerCancelRef = useRef<(() => void) | null>(null); + + const cancelRun = () => { + // TODO: ideally this calls stream.client.runs.cancel(threadId, runId) + stream.stop(); + }; + const handleSendMessage = () => { if (chatInput.trim()) { const newHumanMessage = new HumanMessage({ @@ -126,6 +135,8 @@ export function ThreadView({ chatInput={chatInput} setChatInput={setChatInput} handleSendMessage={handleSendMessage} + isLoading={stream.isLoading} + cancelRun={cancelRun} /> {/* Right Side - Actions & Plan */}
@@ -146,10 +157,35 @@ export function ThreadView({ setSelectedTab(value as "planner" | "programmer") } > - - Planner - Programmer - +
+ + Planner + Programmer + + +
+ {selectedTab === "planner" && + plannerCancelRef.current && ( + + )} + + {selectedTab === "programmer" && + programmerCancelRef.current && ( + + )} +
+
+ @@ -161,6 +197,13 @@ export function ThreadView({ setProgrammerSession={setProgrammerSession} programmerSession={programmerSession} setSelectedTab={setSelectedTab} + onStreamReady={(cancelFn) => { + if (cancelFn) { + plannerCancelRef.current = cancelFn; + } else { + plannerCancelRef.current = null; + } + }} /> )} {!(plannerThreadId && plannerRunId) && ( @@ -182,6 +225,13 @@ export function ThreadView({ graphId={PROGRAMMER_GRAPH_ID} threadId={programmerSession.threadId} runId={programmerSession.runId} + onStreamReady={(cancelFn) => { + if (cancelFn) { + programmerCancelRef.current = cancelFn; + } else { + programmerCancelRef.current = null; + } + }} /> )} {!programmerSession && ( diff --git a/apps/web/src/hooks/useCancelStream.tsx b/apps/web/src/hooks/useCancelStream.tsx new file mode 100644 index 00000000..bc5e0ce1 --- /dev/null +++ b/apps/web/src/hooks/useCancelStream.tsx @@ -0,0 +1,48 @@ +import { UseStream } from "@langchain/langgraph-sdk/react"; +import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { toast } from "sonner"; + +interface UseCancelStreamProps { + stream: UseStream; + threadId?: string; + runId?: string; + streamName: "Planner" | "Programmer"; +} + +export function useCancelStream({ + stream, + threadId, + runId, + streamName, +}: UseCancelStreamProps) { + const cancelRun = async () => { + if (!threadId || !runId) { + toast.error(`Cannot cancel ${streamName}: Missing thread or run ID`); + return; + } + + try { + await stream.client.runs.cancel(threadId, runId); + toast.success(`${streamName} cancelled successfully`, { + description: "The running operation has been stopped", + }); + } catch (error) { + const errorStr = String(error); + const isAbortError = errorStr.toLowerCase().includes("abort"); + + if (isAbortError) { + toast.info(`${streamName} operation cancelled`, { + description: "The stream was successfully stopped", + }); + } else { + console.error(`Error cancelling ${streamName} run:`, error); + toast.error(`Failed to cancel ${streamName}`, { + description: errorStr || "Unknown error occurred", + }); + } + } + }; + + return { cancelRun }; +}