mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
v0.3.14
...
agent/n-y/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62744b4904 |
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -234,6 +234,13 @@
|
||||
"status_failed": "失败",
|
||||
"status_cancelled": "已取消"
|
||||
},
|
||||
"terminate_dialog": {
|
||||
"title": "确定停止该 task?",
|
||||
"body": "Task 会被取消且无法恢复。",
|
||||
"running_note": " 进行中的 task 可能需要几秒才能完全停止。",
|
||||
"keep": "保留运行",
|
||||
"confirm": "停止 task"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "已选择 {{count}} 个",
|
||||
"status": "状态",
|
||||
|
||||
Reference in New Issue
Block a user