feat: livrable ticket #91 — notification fin BackgroundTask enrichie

- Backend: enrichissement HeadlessRendezvous avec requester/target/conversationId
- Frontend: toast 'Requester -> Target completed/...' explicite
- Clic ouvrant viewer de conversation pour visualiser l'échange
This commit is contained in:
2026-07-27 09:05:34 +02:00
parent 02603441c1
commit 038e90ecec
12 changed files with 563 additions and 35 deletions

View File

@ -35,7 +35,7 @@
import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain";
import type { Agent, DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ConversationViewer } from "@/features/conversations";
import {
@ -98,6 +98,14 @@ interface BackgroundTaskToast {
agentId: string;
taskId: string;
state: string;
title: string;
subtitle: string;
conversationId?: string;
}
interface PendingConversationOpen {
projectId: string;
conversationId: string;
}
function isTerminalBackgroundTaskEvent(
@ -112,9 +120,30 @@ function isTerminalBackgroundTaskEvent(
);
}
function shortTaskId(id: string): string {
return id.slice(0, 8);
}
function fallbackAgentLabel(id: string): string {
return shortTaskId(id);
}
function agentLabel(agents: Agent[], agentId: string): string {
return (
agents.find((candidate) => candidate.id === agentId)?.name ??
fallbackAgentLabel(agentId)
);
}
export function ProjectsView() {
const vm = useProjects();
const { system, window: windowGateway, focusedProject, git } = useGateways();
const {
system,
window: windowGateway,
focusedProject,
git,
agent,
} = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent),
@ -142,6 +171,8 @@ export function ProjectsView() {
const [viewerConversationId, setViewerConversationId] = useState<
string | null
>(null);
const [pendingConversationOpen, setPendingConversationOpen] =
useState<PendingConversationOpen | null>(null);
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
@ -195,6 +226,14 @@ export function ProjectsView() {
setViewerConversationId(null);
}, [active?.id]);
useEffect(() => {
if (!active || pendingConversationOpen?.projectId !== active.id) return;
setSettingsSection(null);
setViewerConversationId(pendingConversationOpen.conversationId);
dismissFloating();
setPendingConversationOpen(null);
}, [active?.id, pendingConversationOpen]);
// Publish the focused project (#47) so detached panel-only windows follow the
// main window: they render this project, or an "open a project" shell when
// none is active. Publish on EVERY change of `active` — including
@ -211,17 +250,44 @@ export function ProjectsView() {
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);
void (async () => {
const hasRendezvousAgents = Boolean(
event.requesterAgentId && event.targetAgentId,
);
const labels = hasRendezvousAgents
? await agent
.listAgents(event.projectId)
.then((agents) => ({
requester: agentLabel(agents, event.requesterAgentId!),
target: agentLabel(agents, event.targetAgentId!),
}))
.catch(() => ({
requester: fallbackAgentLabel(event.requesterAgentId!),
target: fallbackAgentLabel(event.targetAgentId!),
}))
: null;
if (cancelled) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
title: labels
? `${labels.requester} -> ${labels.target} ${event.state}`
: `Background task ${event.state}`,
subtitle: labels
? `Task ${shortTaskId(event.taskId)}`
: `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`,
...(event.conversationId
? { conversationId: event.conversationId }
: {}),
};
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;
@ -230,7 +296,7 @@ export function ProjectsView() {
cancelled = true;
unsubscribe?.();
};
}, [system]);
}, [agent, system]);
const activeLayoutKind = activeLayout?.kind ?? "terminal";
@ -367,6 +433,29 @@ export function ProjectsView() {
dismissFloating();
}
async function handleTaskToastClick(toast: BackgroundTaskToast) {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
if (toast.conversationId) {
setPendingConversationOpen({
projectId: toast.projectId,
conversationId: toast.conversationId,
});
if (projectOpen) {
vm.activateTab(toast.projectId);
} else {
await vm.openProject(toast.projectId);
}
return;
}
if (projectOpen) vm.activateTab(toast.projectId);
setSettingsSection(null);
setViewerConversationId(null);
setPlacement("work", "floating");
}
// ── Menus (#26) ─────────────────────────────────────────────────────────
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
// the placement actions directly (closed / floating / docked left/right /
@ -804,24 +893,13 @@ export function ProjectsView() {
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);
setSettingsSection(null);
setViewerConversationId(null);
setPlacement("work", "floating");
setTaskToasts((prev) =>
prev.filter((item) => item.id !== toast.id),
);
}}
onClick={() => void handleTaskToastClick(toast)}
>
<span className="block text-sm font-medium text-content">
Background task {toast.state}
{toast.title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
{toast.subtitle}
</span>
</button>
))}