diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index bb076a0b..43be0ca6 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -138,7 +138,9 @@ def _metadata_repo(metadata: dict[str, Any]) -> tuple[str, str, str]: def _run_status_to_agent_status(thread_status: str | None, run_status: str | None) -> str: if thread_status == "busy" or run_status in {"pending", "running"}: return "running" - if run_status in {"error", "failed", "timeout", "interrupted"}: + if run_status in {"interrupted", "cancelled"}: + return "interrupted" + if run_status in {"error", "failed", "timeout"}: return "error" if run_status == "success": return "finished" @@ -444,7 +446,7 @@ async def cancel_dashboard_thread( run_id = metadata.get("latest_run_id") if isinstance(run_id, str) and run_id: try: - await client.runs.cancel(thread_id, run_id, wait=False) + await client.runs.cancel(thread_id, run_id, wait=False, action="interrupt") except Exception: logger.debug("Could not cancel run %s for thread %s", run_id, thread_id, exc_info=True) diff --git a/tests/test_dashboard_repo_optional.py b/tests/test_dashboard_repo_optional.py index 025b3a3b..95f32024 100644 --- a/tests/test_dashboard_repo_optional.py +++ b/tests/test_dashboard_repo_optional.py @@ -42,6 +42,13 @@ def test_thread_summary_keeps_repo_when_present() -> None: assert summary["repoFullName"] == "octo/repo" +def test_thread_summary_reports_interrupted_run_status() -> None: + summary = thread_api._thread_summary( + {"thread_id": "t3", "metadata": {"latest_run_status": "interrupted"}} + ) + assert summary["status"] == "interrupted" + + class _FakeThreadsClient: async def create( self, *, thread_id: str, metadata: dict[str, Any], if_exists: str diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index fa398a3e..c91e4a5c 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -7,7 +7,7 @@ import type { ModelSelection } from "@/lib/agents/useModelOptions"; import { AgentPromptBar } from "@/components/agents/AgentPromptBar"; import { AgentsShell } from "@/components/agents/AgentsSidebar"; import { MessageView } from "@/components/agents/ported"; -import { useSendAgentMessage } from "@/lib/agents/queries"; +import { useCancelAgentThread, useSendAgentMessage } from "@/lib/agents/queries"; import { dropPendingPrompts, getPendingPrompts } from "@/lib/agents/pendingPrompts"; import { useAgentThreadStream } from "@/lib/agents/useThreadStream"; import { useModelOptions } from "@/lib/agents/useModelOptions"; @@ -19,6 +19,7 @@ interface AgentThreadViewProps { export function AgentThreadView({ user, thread }: AgentThreadViewProps) { const sendMessage = useSendAgentMessage(thread.id); + const cancelThread = useCancelAgentThread(thread.id); useAgentThreadStream(thread.id, thread.status === "running"); const [pendingPrompts, setPendingPrompts] = useState>(() => getPendingPrompts(thread.id), @@ -77,7 +78,8 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { }, [thread.messages, pendingPrompts]); const hasMessages = displayMessages.length > 0; - const isStreaming = thread.status === "running" || pendingPrompts.length > 0; + const hasActiveRun = thread.status === "running"; + const isStreaming = hasActiveRun || pendingPrompts.length > 0; return ( @@ -95,7 +97,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { sendMessage.mutate({ @@ -104,6 +106,8 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { effort: activeSelection?.effort ?? null, }) } + onStop={() => cancelThread.mutate()} + stopping={cancelThread.isPending} models={models} selection={activeSelection} onSelectionChange={setSelection} @@ -118,7 +122,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { sendMessage.mutate({ @@ -127,6 +131,8 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { effort: activeSelection?.effort ?? null, }) } + onStop={() => cancelThread.mutate()} + stopping={cancelThread.isPending} models={models} selection={activeSelection} onSelectionChange={setSelection} diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index b22acc21..c480afb3 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, ChevronDown, LoaderCircle } from "lucide-react" +import { ArrowUp, ChevronDown, LoaderCircle, Square } from "lucide-react" import { memo, useCallback, @@ -23,6 +23,9 @@ export interface CloudPromptBarProps { disabled?: boolean busy?: boolean onSubmit?: (value: string) => void + /** Called to stop the running agent. When set, the send button becomes a stop button while busy and the input is empty. */ + onStop?: () => void + stopping?: boolean models?: Array selection?: ModelSelection | null onSelectionChange?: (next: ModelSelection) => void @@ -39,6 +42,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ disabled = false, busy = false, onSubmit, + onStop, + stopping = false, models = [], selection = null, onSelectionChange, @@ -103,6 +108,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ } const pickerDisabled = combos.length === 0 || !onSelectionChange + const showStop = busy && !disabled && !value.trim() && !!onStop return (
- + {showStop ? ( + + ) : ( + + )}