Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
62744b4904 feat(issues): confirm before terminating a single task
The two issue-detail surfaces that stop a single agent task — the
sticky AgentLiveCard banner and the active rows inside
ExecutionLogSection — cancelled on the first click. Task
cancellation is irreversible, and a misclick on a long-running run
was costly with no way to recover.

Both entry points now route through a shared
TerminateTaskConfirmDialog (AlertDialog with destructive confirm),
mirroring the pattern the Agents list row actions already use for
the "cancel all tasks" flow. The running-state note about a few
seconds to fully halt is only shown when the task is actually
running or dispatched.

Chat window pending-pill Stop is intentionally not affected — it
is fire-and-forget with the UI clearing optimistically, and a
confirm step there would interrupt chat flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 14:53:31 +08:00
6 changed files with 166 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent as rtlFireEvent, render, screen, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { AgentTask } from "@multica/core/types/agent";
import enCommon from "../../locales/en/common.json";
@@ -251,6 +251,53 @@ describe("AgentLiveCard queued rendering", () => {
expect(screen.getByText("Stop")).toBeTruthy();
});
it("Stop button opens a confirm dialog and only calls cancelTask after the user confirms", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
// First click should not hit the API — it only opens the confirm.
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
// Confirm — now the cancel fires.
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Stop task" }));
});
expect(mockApi.cancelTask).toHaveBeenCalledWith("issue-1", "task-r");
});
it("Stop confirm dialog dismisses without cancelling when the user picks Keep running", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Keep running" }));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
});
it("running tasks sort above queued tasks so the sticky slot stays on the active one", async () => {
const runningTask = makeTask("task-r", { status: "running" });
const queuedTask = makeTask("task-q", {

View File

@@ -15,6 +15,7 @@ import {
type TimelineItem,
} from "../../common/task-transcript";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// AgentLiveCard renders a sticky banner at the top of the issue's main
// column for every active task. Each banner shows "agent X is working",
@@ -274,6 +275,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
const { t } = useT("issues");
const [elapsed, setElapsed] = useState("");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const isQueued = task.status === "queued";
@@ -299,6 +301,11 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
}
}, [task.id, issueId, cancelling, t]);
const requestCancel = useCallback(() => {
if (cancelling) return;
setConfirmOpen(true);
}, [cancelling]);
const toolCount = items.filter((i) => i.type === "tool_use").length;
// Queued tasks render with a non-spinning Clock and dimmer accent so the
@@ -344,7 +351,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
/>
)}
<button
onClick={handleCancel}
onClick={requestCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
title={t(($) => $.agent_live.stop_tooltip)}
@@ -354,6 +361,12 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
</button>
</div>
</div>
<TerminateTaskConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={() => void handleCancel()}
showRunningNote={!isQueued}
/>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { TranscriptButton } from "../../common/task-transcript";
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// Mask gradient that fades the trigger-summary text into transparency at
// the right edge. Mirrors the pattern used by the desktop tab bar
@@ -255,6 +256,7 @@ function useStatusLabel(status: AgentTask["status"]): string {
function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
const { t } = useT("issues");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const tone = STATUS_TONE[task.status];
const label = useStatusLabel(task.status);
const trigger = useTriggerText(task);
@@ -275,6 +277,11 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
}
};
const requestCancel = () => {
if (cancelling) return;
setConfirmOpen(true);
};
return (
<RowShell task={task}>
<TriggerText text={trigger} />
@@ -298,7 +305,7 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
render={
<button
type="button"
onClick={handleCancel}
onClick={requestCancel}
disabled={cancelling}
aria-label={t(($) => $.execution_log.cancel_task_aria)}
/>
@@ -314,6 +321,12 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
<TooltipContent>{t(($) => $.execution_log.cancel_task_tooltip)}</TooltipContent>
</Tooltip>
</RowActions>
<TerminateTaskConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={() => void handleCancel()}
showRunningNote={task.status === "running" || task.status === "dispatched"}
/>
</RowShell>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useT } from "../../i18n";
// Reusable confirm step for the two issue-detail surfaces that terminate
// a single agent task — the sticky AgentLiveCard banner and the row
// action inside ExecutionLogSection. Task cancellation is irreversible
// and a misclick on a long-running run is costly, so both entry points
// route through this dialog instead of firing the cancel request on the
// first click.
//
// The dialog is fully controlled by the caller (which already owns the
// confirmCancel state alongside the in-flight cancelling state). When the
// caller signals it is currently terminating, the action button shows a
// disabled state so a second click cannot race the in-flight request.
interface TerminateTaskConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
// Running tasks may take a few seconds to fully halt on the daemon
// side. Queued tasks cancel immediately, so we omit the note to keep
// the copy honest.
showRunningNote?: boolean;
}
export function TerminateTaskConfirmDialog({
open,
onOpenChange,
onConfirm,
showRunningNote = false,
}: TerminateTaskConfirmDialogProps) {
const { t } = useT("issues");
if (!open) return null;
return (
<AlertDialog open onOpenChange={onOpenChange}>
<AlertDialogContent
// Stop clicks inside the dialog from bubbling to the row /
// banner underneath (the dialog can render inside a clickable
// ancestor — e.g. an ExecutionLogSection row).
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.terminate_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.terminate_dialog.body)}
{showRunningNote && t(($) => $.terminate_dialog.running_note)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.terminate_dialog.keep)}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
onOpenChange(false);
onConfirm();
}}
>
{t(($) => $.terminate_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -239,6 +239,13 @@
"status_failed": "Failed",
"status_cancelled": "Cancelled"
},
"terminate_dialog": {
"title": "Stop this task?",
"body": "The task will be cancelled and cannot be resumed.",
"running_note": " Running work may take a few seconds to fully halt.",
"keep": "Keep running",
"confirm": "Stop task"
},
"batch": {
"selected": "{{count}} selected",
"status": "Status",

View File

@@ -234,6 +234,13 @@
"status_failed": "失败",
"status_cancelled": "已取消"
},
"terminate_dialog": {
"title": "确定停止该 task",
"body": "Task 会被取消且无法恢复。",
"running_note": " 进行中的 task 可能需要几秒才能完全停止。",
"keep": "保留运行",
"confirm": "停止 task"
},
"batch": {
"selected": "已选择 {{count}} 个",
"status": "状态",