feat(frontend): boutons cancel/retry des tâches de fond (B8)

Câble l'action utilisateur sur les commandes Tauri B8 :

- port WorkStateGateway : ajout de cancelBackgroundTask/retryBackgroundTask
  (le read-model se rafraîchit via l'événement domaine backgroundTaskChanged ;
  retry rejoue sous un NOUVEL id de tâche).
- adapter Tauri : invoke cancel_background_task/retry_background_task.
- adapter mock : no-ops (le refresh réel est piloté par les événements).
- ProjectWorkStatePanel (BackgroundTaskRow) : état busy + affichage d'erreur
  + refresh après action.
- test ProjectsView.ls7 : stub de gateway complété.

Build vert : npm run build (tsc --noEmit + vite build), vitest 449 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:53:32 +02:00
parent 8cac1470ac
commit c179f93a26
5 changed files with 77 additions and 7 deletions

View File

@ -1701,6 +1701,14 @@ export class MockWorkStateGateway implements WorkStateGateway {
this.states.get(projectId) ?? { agents: [], conversations: [] },
);
}
async cancelBackgroundTask(_taskId: string): Promise<void> {
// No-op in the mock; real refresh is driven by domain events.
}
async retryBackgroundTask(_taskId: string): Promise<void> {
// No-op in the mock; real refresh is driven by domain events.
}
}
/**

View File

@ -16,4 +16,12 @@ export class TauriWorkStateGateway implements WorkStateGateway {
const state = await invoke<unknown>("get_project_work_state", { projectId });
return normalizeProjectWorkState(state);
}
async cancelBackgroundTask(taskId: string): Promise<void> {
await invoke<unknown>("cancel_background_task", { taskId });
}
async retryBackgroundTask(taskId: string): Promise<void> {
await invoke<unknown>("retry_background_task", { taskId });
}
}

View File

@ -63,7 +63,11 @@ function fixedWorkState(): WorkStateGateway {
},
],
};
return { getProjectWorkState: async () => structuredClone(state) };
return {
getProjectWorkState: async () => structuredClone(state),
cancelBackgroundTask: async () => {},
retryBackgroundTask: async () => {},
};
}
/** A conversation gateway that returns one identifiable turn for any thread. */

View File

@ -323,11 +323,36 @@ function InboxRow({ item }: { item: InboxItem }) {
);
}
function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) {
function BackgroundTaskRow({
task,
onRefresh,
}: {
task: BackgroundCompletion;
onRefresh: () => Promise<void>;
}) {
const { workState } = useGateways();
const [open, setOpen] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = task.status === "running" || task.status === "pending";
const canRetry = task.status === "failed" || task.status === "cancelled";
async function runAction(
action: (taskId: string) => Promise<void>,
): Promise<void> {
setActionBusy(true);
setMessage(null);
try {
await action(task.taskId);
await onRefresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
@ -348,8 +373,11 @@ function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) {
<Button
size="sm"
variant="ghost"
disabled={!canCancel}
title="Backend command not exposed yet"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.cancelBackgroundTask(id))
}
>
Cancel
</Button>
@ -364,8 +392,11 @@ function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) {
<Button
size="sm"
variant="ghost"
disabled={!canRetry}
title="Backend command not exposed yet"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() =>
void runAction((id) => workState.retryBackgroundTask(id))
}
>
Retry
</Button>
@ -376,6 +407,11 @@ function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) {
{shortTicket(task.taskId)}
</code>
</div>
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{open && hasOutput && (
<pre
aria-label={`task ${shortTicket(task.taskId)} output`}
@ -609,7 +645,11 @@ function AgentRow({
className="mt-1 flex flex-col gap-1"
>
{backgroundTasks.map((task) => (
<BackgroundTaskRow key={task.taskId} task={task} />
<BackgroundTaskRow
key={task.taskId}
task={task}
onRefresh={refreshAfterAction}
/>
))}
</ul>
</div>

View File

@ -666,6 +666,16 @@ export interface PermissionGateway {
export interface WorkStateGateway {
/** Reads the current per-agent live/offline and idle/busy state for a project. */
getProjectWorkState(projectId: string): Promise<ProjectWorkState>;
/**
* Cancels a running/pending background task by id. The read-model refreshes
* through the `backgroundTaskChanged` domain event.
*/
cancelBackgroundTask(taskId: string): Promise<void>;
/**
* Re-runs a failed/cancelled background task under a **new** task id (the
* original id is never reused).
*/
retryBackgroundTask(taskId: string): Promise<void>;
}
/** One paginated page request over a conversation transcript (LS7). */