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:
@ -100,6 +100,15 @@ function normalizeBackgroundTask(
|
||||
fallbackAgentId: string,
|
||||
): BackgroundCompletion {
|
||||
const task = isRecord(value) ? value : {};
|
||||
const requesterAgentId = optionalString(
|
||||
task.requesterAgentId ?? task.requester_agent_id,
|
||||
);
|
||||
const targetAgentId = optionalString(
|
||||
task.targetAgentId ?? task.target_agent_id,
|
||||
);
|
||||
const conversationId = optionalString(
|
||||
task.conversationId ?? task.conversation_id,
|
||||
);
|
||||
return {
|
||||
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
||||
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
||||
@ -110,6 +119,9 @@ function normalizeBackgroundTask(
|
||||
summary: nullableString(task.summary),
|
||||
stdoutTail: nullableString(task.stdoutTail),
|
||||
stderrTail: nullableString(task.stderrTail),
|
||||
...(requesterAgentId ? { requesterAgentId } : {}),
|
||||
...(targetAgentId ? { targetAgentId } : {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
updatedAtMs: numberValue(task.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
@ -300,6 +300,9 @@ export type DomainEvent =
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
state: string;
|
||||
requesterAgentId?: string;
|
||||
targetAgentId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
| {
|
||||
type: "agentInboxChanged";
|
||||
@ -608,6 +611,9 @@ export interface BackgroundCompletion {
|
||||
summary: string | null;
|
||||
stdoutTail: string | null;
|
||||
stderrTail: string | null;
|
||||
requesterAgentId?: string;
|
||||
targetAgentId?: string;
|
||||
conversationId?: string;
|
||||
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
@ -90,10 +90,17 @@ function fixedConversation(): ConversationGateway {
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(project: MockProjectGateway) {
|
||||
const agent = new MockAgentGateway();
|
||||
function renderView(
|
||||
project: MockProjectGateway,
|
||||
overrides: {
|
||||
agent?: MockAgentGateway;
|
||||
system?: MockSystemGateway;
|
||||
} = {},
|
||||
) {
|
||||
const agent = overrides.agent ?? new MockAgentGateway();
|
||||
const system = overrides.system ?? new MockSystemGateway();
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
system,
|
||||
project,
|
||||
agent,
|
||||
profile: new MockProfileGateway(),
|
||||
@ -224,4 +231,75 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("labels rendezvous completion toasts with requester and target, then opens the conversation", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const agent = new MockAgentGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const requester = await agent.createAgent(created.id, {
|
||||
name: "Main",
|
||||
profileId: "codex",
|
||||
});
|
||||
const target = await agent.createAgent(created.id, {
|
||||
name: "DevBackend",
|
||||
profileId: "codex",
|
||||
});
|
||||
renderView(project, { agent, system });
|
||||
|
||||
await openProjectAndWorkTab("/p/a");
|
||||
system.emit({
|
||||
type: "backgroundTaskChanged",
|
||||
projectId: created.id,
|
||||
agentId: target.id,
|
||||
taskId: "task-rendezvous-91",
|
||||
state: "completed",
|
||||
requesterAgentId: requester.id,
|
||||
targetAgentId: target.id,
|
||||
conversationId: CONV_ID,
|
||||
});
|
||||
|
||||
const toast = await screen.findByRole("button", {
|
||||
name: /Main -> DevBackend completed/i,
|
||||
});
|
||||
expect(within(toast).getByText("Task task-ren")).toBeTruthy();
|
||||
|
||||
fireEvent.click(toast);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Main -> DevBackend completed/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the generic background-task toast when rendezvous agent ids are absent", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const system = new MockSystemGateway();
|
||||
renderView(project, { system });
|
||||
|
||||
await openProjectAndWorkTab("/p/a");
|
||||
system.emit({
|
||||
type: "backgroundTaskChanged",
|
||||
projectId: created.id,
|
||||
agentId: "agent-generic-1",
|
||||
taskId: "task-generic-1",
|
||||
state: "failed",
|
||||
});
|
||||
|
||||
const toast = await screen.findByRole("button", {
|
||||
name: /Background task failed/i,
|
||||
});
|
||||
expect(within(toast).getByText("agent-ge · task-gen")).toBeTruthy();
|
||||
|
||||
fireEvent.click(toast);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||
).toBeNull();
|
||||
expect(await screen.findByText("Work")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -180,6 +180,7 @@ describe("ProjectWorkStatePanel", () => {
|
||||
// Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase,
|
||||
// `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted
|
||||
// when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs.
|
||||
// Headless rendezvous tasks may carry requester/target/conversation context.
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
agents: [
|
||||
@ -200,6 +201,9 @@ describe("ProjectWorkStatePanel", () => {
|
||||
taskId: "task-queued-1",
|
||||
kind: "headlessRendezvous",
|
||||
state: "queued",
|
||||
requesterAgentId: "agent-main",
|
||||
targetAgentId: "agent-bg",
|
||||
conversationId: "conversation-rdv",
|
||||
createdAtMs: 12,
|
||||
updatedAtMs: 13,
|
||||
},
|
||||
@ -241,6 +245,9 @@ describe("ProjectWorkStatePanel", () => {
|
||||
expect(tasks[2]?.stderrTail).toBe("boom");
|
||||
// `summary` (ticket #5) is carried through when present.
|
||||
expect(tasks[2]?.summary).toBe("process failed");
|
||||
expect(tasks[1]?.requesterAgentId).toBe("agent-main");
|
||||
expect(tasks[1]?.targetAgentId).toBe("agent-bg");
|
||||
expect(tasks[1]?.conversationId).toBe("conversation-rdv");
|
||||
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
|
||||
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user