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

View File

@ -666,6 +666,16 @@ export interface PermissionGateway {
export interface WorkStateGateway { export interface WorkStateGateway {
/** Reads the current per-agent live/offline and idle/busy state for a project. */ /** Reads the current per-agent live/offline and idle/busy state for a project. */
getProjectWorkState(projectId: string): Promise<ProjectWorkState>; 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). */ /** One paginated page request over a conversation transcript (LS7). */