feat(frontend): surface UI des tâches de fond (F1-F4)

Expose les tâches de fond côté front : modèle domaine et
normalisation du work state, panneau ProjectWorkStatePanel,
intégration dans LayoutGrid et ProjectsView, et abonnement aux
événements backgroundTaskChanged / agentInboxChanged /
agentWakeChanged pour rafraîchir l'état. npm run build + npm test
(449) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:33:51 +02:00
parent e05edc6863
commit 5d88c952ec
6 changed files with 450 additions and 9 deletions

View File

@ -30,7 +30,7 @@
import { useEffect, useState } from "react";
import type { LayoutInfo } from "@/domain";
import type { DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
@ -69,6 +69,26 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "git", label: "Git" },
];
interface BackgroundTaskToast {
id: string;
projectId: string;
agentId: string;
taskId: string;
state: string;
}
function isTerminalBackgroundTaskEvent(
event: DomainEvent,
): event is Extract<DomainEvent, { type: "backgroundTaskChanged" }> {
return (
event.type === "backgroundTaskChanged" &&
(event.state === "completed" ||
event.state === "failed" ||
event.state === "cancelled" ||
event.state === "delivered")
);
}
export function ProjectsView() {
const vm = useProjects();
const { system } = useGateways();
@ -86,6 +106,7 @@ export function ProjectsView() {
const [viewerConversationId, setViewerConversationId] = useState<
string | null
>(null);
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
@ -99,6 +120,32 @@ export function ProjectsView() {
setViewerConversationId(null);
}, [active?.id]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system.onDomainEvent((event) => {
if (!isTerminalBackgroundTaskEvent(event)) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
};
setTaskToasts((prev) => [...prev.slice(-2), toast]);
window.setTimeout(() => {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
}, 7000);
}).then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system]);
const activeLayoutKind = activeLayout?.kind ?? "terminal";
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
@ -394,6 +441,32 @@ export function ProjectsView() {
)}
</main>
</div>
{taskToasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
{taskToasts.map((toast) => (
<button
key={toast.id}
type="button"
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
onClick={() => {
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
if (projectOpen) vm.activateTab(toast.projectId);
setSidebarCollapsed(false);
setSidebarTab("work");
setViewerConversationId(null);
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
}}
>
<span className="block text-sm font-medium text-content">
Background task {toast.state}
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
</span>
</button>
))}
</div>
)}
</div>
);
}