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:
@ -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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user