From 5d88c952ec833a3871c34dd915aa586c39882aac Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 2 Jul 2026 21:33:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20surface=20UI=20des=20t=C3=A2c?= =?UTF-8?q?hes=20de=20fond=20(F1-F4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/adapters/workStateNormalization.ts | 111 ++++++++++++- frontend/src/domain/index.ts | 48 ++++++ frontend/src/features/layout/LayoutGrid.tsx | 66 +++++++- .../src/features/projects/ProjectsView.tsx | 75 ++++++++- .../workstate/ProjectWorkStatePanel.tsx | 154 ++++++++++++++++++ .../features/workstate/useProjectWorkState.ts | 5 +- 6 files changed, 450 insertions(+), 9 deletions(-) diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 1499e0b..49e0803 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -1,9 +1,11 @@ import type { AgentTicketState, AgentWorkState, + BackgroundCompletion, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + InboxItem, ProjectWorkState, TicketWorkSource, TicketWorkStatus, @@ -24,10 +26,22 @@ function numberValue(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function arrayValue(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + function normalizeSource(value: unknown): TicketWorkSource { if (!isRecord(value)) return { kind: "human" }; if (value.kind === "agent") { @@ -66,6 +80,57 @@ function normalizeTicket(value: unknown, index: number): AgentTicketState { }; } +function normalizeBackgroundStatus( + value: unknown, +): BackgroundCompletion["status"] { + const raw = typeof value === "string" ? value.toLowerCase() : ""; + if (raw === "completed" || raw === "success" || raw === "succeeded") return "completed"; + if (raw === "failed" || raw === "failure") return "failed"; + if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled"; + if (raw === "running" || raw === "started") return "running"; + if (raw === "delivered") return "delivered"; + return "pending"; +} + +function normalizeBackgroundTask( + value: unknown, + index: number, + fallbackAgentId: string, +): BackgroundCompletion { + const task = isRecord(value) ? value : {}; + return { + taskId: stringValue(task.taskId, `legacy-task-${index}`), + ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), + projectId: stringValue(task.projectId), + kind: stringValue(task.kind, "background"), + status: normalizeBackgroundStatus(task.status ?? task.state), + exitCode: nullableNumber(task.exitCode), + stdoutTail: nullableString(task.stdoutTail), + stderrTail: nullableString(task.stderrTail), + finishedAtMs: nullableNumber(task.finishedAtMs), + }; +} + +function normalizeInboxItem( + value: unknown, + index: number, + fallbackAgentId: string, +): InboxItem { + const item = isRecord(value) ? value : {}; + return { + id: stringValue(item.id, `legacy-inbox-${index}`), + agentId: stringValue(item.agentId ?? item.agent_id, fallbackAgentId), + source: stringValue(item.source, "unknown"), + kind: stringValue(item.kind, "message"), + body: stringValue(item.body), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms, 0), + ...(optionalString(item.correlationId ?? item.correlation_id) + ? { correlationId: optionalString(item.correlationId ?? item.correlation_id) } + : {}), + priority: numberValue(item.priority, 0), + }; +} + function normalizeAgent(value: unknown, index: number): AgentWorkState { const agent = isRecord(value) ? value : {}; const liveKind: "pty" | "structured" = @@ -78,13 +143,26 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState { } : undefined; + const agentId = stringValue(agent.agentId, `legacy-agent-${index}`); + const inbox = arrayValue(agent.inbox).map((item, itemIndex) => + normalizeInboxItem(item, itemIndex, agentId), + ); + const backgroundTasks = arrayValue( + agent.backgroundTasks ?? agent.backgroundCompletions, + ).map((task, taskIndex) => + normalizeBackgroundTask(task, taskIndex, agentId), + ); + return { - agentId: stringValue(agent.agentId, `legacy-agent-${index}`), + agentId, name: stringValue(agent.name, "Unnamed agent"), profileId: stringValue(agent.profileId, "unknown"), ...(live ? { live } : {}), busy: normalizeBusy(agent.busy), tickets: arrayValue(agent.tickets).map(normalizeTicket), + inboxDepth: numberValue(agent.inboxDepth, inbox.length), + inbox, + backgroundTasks, }; } @@ -116,10 +194,6 @@ function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPrevi }; } -function nullableString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - function normalizeConversation( value: unknown, index: number, @@ -138,8 +212,33 @@ function normalizeConversation( export function normalizeProjectWorkState(value: unknown): ProjectWorkState { const state = isRecord(value) ? value : {}; + const agents = arrayValue(state.agents).map(normalizeAgent); + const topLevelTasks = arrayValue( + state.backgroundTasks ?? state.backgroundCompletions, + ).map((task, index) => normalizeBackgroundTask(task, index, "")); + for (const task of topLevelTasks) { + const owner = agents.find((agent) => agent.agentId === task.ownerAgentId); + if (!owner) continue; + owner.backgroundTasks ??= []; + if (!owner.backgroundTasks.some((item) => item.taskId === task.taskId)) { + owner.backgroundTasks.push(task); + } + } + const topLevelInbox = arrayValue(state.inboxItems).map((item, index) => + normalizeInboxItem(item, index, ""), + ); + for (const item of topLevelInbox) { + const owner = agents.find((agent) => agent.agentId === item.agentId); + if (!owner) continue; + owner.inbox ??= []; + owner.inboxDepth ??= 0; + if (!owner.inbox.some((entry) => entry.id === item.id)) { + owner.inbox.push(item); + owner.inboxDepth = Math.max(owner.inboxDepth, owner.inbox.length); + } + } return { - agents: arrayValue(state.agents).map(normalizeAgent), + agents, conversations: arrayValue(state.conversations).map(normalizeConversation), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ae67206..5b8c260 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -20,6 +20,26 @@ export type DomainEvent = | { type: "agentExited"; agentId: string; code: number } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } + | { + type: "backgroundTaskChanged"; + projectId: string; + taskId: string; + agentId: string; + state: string; + } + | { + type: "agentInboxChanged"; + agentId: string; + depth: number; + action: string; + } + | { + type: "agentWakeChanged"; + projectId: string; + agentId: string; + action: string; + reason?: string; + } | { /** * A delegation is ready to be injected into the agent's native terminal @@ -170,6 +190,31 @@ export interface AgentTicketState { taskLen: number; } +/** One first-class background task visible in the work-state read-model. */ +export interface BackgroundCompletion { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered"; + exitCode: number | null; + stdoutTail: string | null; + stderrTail: string | null; + finishedAtMs: number | null; +} + +/** One item currently pending in an agent's inbox. */ +export interface InboxItem { + id: string; + agentId: string; + source: string; + kind: string; + body: string; + createdAtMs: number; + correlationId?: string; + priority: number; +} + /** Availability of a compact conversation summary in the work-state read-model. */ export type ConversationPreviewStatus = | "ready" @@ -205,6 +250,9 @@ export interface AgentWorkState { live?: LiveWorkSession; busy: WorkBusyState; tickets: AgentTicketState[]; + inboxDepth?: number; + inbox?: InboxItem[]; + backgroundTasks?: BackgroundCompletion[]; } /** Minimal read-only live-state surface for a project. */ diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index a586320..2637afa 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -317,6 +317,16 @@ function LeafView({ ? workState?.agents.find((row) => row.agentId === agentId) : undefined; const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress"); + const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? []; + const inboxDepth = Math.max( + agentWork?.inboxDepth ?? 0, + agentWork?.inbox?.length ?? 0, + queuedTickets.length, + ); + const completedBackgroundTasks = + agentWork?.backgroundTasks?.filter((task) => + task.status === "completed" || task.status === "failed" || task.status === "cancelled", + ) ?? []; const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const historyConversationId = @@ -661,7 +671,7 @@ function LeafView({ )} - {agentId && (busyByWorkState || historyConversationId) && ( + {agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
)} + {!busyByWorkState && inboxDepth > 0 && ( + + Queued · {inboxDepth} + + )} + {busyByWorkState && inboxDepth > 0 && ( + + Queued {inboxDepth} + + )} + {completedBackgroundTasks.length > 0 && ( + + Task done {completedBackgroundTasks.length} + + )} {busyByWorkState && (
+ {taskToasts.length > 0 && ( +
+ {taskToasts.map((toast) => ( + + ))} +
+ )} ); } diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index b946d9d..ad52c12 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -8,8 +8,10 @@ import { Component, type ReactNode, useState } from "react"; import type { AgentTicketState, AgentWorkState, + BackgroundCompletion, ConversationPreviewStatus, ConversationWorkSummary, + InboxItem, LeafCell, } from "@/domain"; import { useGateways } from "@/app/di"; @@ -85,6 +87,30 @@ function formatStatus(status: AgentTicketState["status"]): string { return status === "inProgress" ? "In progress" : "Queued"; } +function formatTaskStatus(status: BackgroundCompletion["status"]): string { + switch (status) { + case "running": + return "Running"; + case "completed": + return "Completed"; + case "failed": + return "Failed"; + case "cancelled": + return "Cancelled"; + case "delivered": + return "Delivered"; + case "pending": + return "Pending"; + } +} + +function taskStatusClass(status: BackgroundCompletion["status"]): string { + if (status === "running" || status === "pending") return "bg-warning/15 text-warning"; + if (status === "completed" || status === "delivered") return "bg-success/10 text-success"; + if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger"; + return "bg-raised text-muted"; +} + function requesterLabel(ticket: AgentTicketState): string { if (ticket.source.kind === "human") return "Human"; const id = shortTicket(ticket.source.agentId); @@ -278,6 +304,92 @@ function TicketRow({ ); } +function InboxRow({ item }: { item: InboxItem }) { + return ( +
  • +
    + + {item.kind} + + {item.body} + + {shortTicket(item.id)} + +
    +
  • + ); +} + +function BackgroundTaskRow({ task }: { task: BackgroundCompletion }) { + const [open, setOpen] = useState(false); + const hasOutput = Boolean(task.stdoutTail || task.stderrTail); + const canCancel = task.status === "running" || task.status === "pending"; + const canRetry = task.status === "failed" || task.status === "cancelled"; + return ( +
  • +
    + + {formatTaskStatus(task.status)} + + + {task.kind} + {task.exitCode !== null && ( + · exit {task.exitCode} + )} + + + + + + {shortTicket(task.taskId)} + +
    + {open && hasOutput && ( +
    +          {task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
    +          {task.stdoutTail && task.stderrTail ? "\n\n" : ""}
    +          {task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
    +        
    + )} +
  • + ); +} + function AgentRow({ agent, conversations, @@ -302,6 +414,13 @@ function AgentRow({ const busyState = agent.busy ?? { state: "idle" }; const busy = busyState.state === "busy"; const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position); + const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs); + const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort((a, b) => { + const aDone = a.finishedAtMs ?? 0; + const bDone = b.finishedAtMs ?? 0; + return bDone - aDone || a.taskId.localeCompare(b.taskId); + }); + const inboxDepth = Math.max(agent.inboxDepth ?? 0, inbox.length, tickets.length); const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id)); const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId); const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent); @@ -390,6 +509,14 @@ function AgentRow({ > {busy ? "Busy" : "Idle"} + {inboxDepth > 0 && ( + + Queue {inboxDepth} + + )} {busyState.state === "busy" && ( )} + {inbox.length > 0 && ( +
    +

    + Inbox +

    +
      + {inbox.map((item) => ( + + ))} +
    +
    + )} + {backgroundTasks.length > 0 && ( +
    +

    + Background tasks +

    +
      + {backgroundTasks.map((task) => ( + + ))} +
    +
    + )} ); } diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts index 4c0ffd9..018d4fc 100644 --- a/frontend/src/features/workstate/useProjectWorkState.ts +++ b/frontend/src/features/workstate/useProjectWorkState.ts @@ -56,7 +56,10 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode event.type === "agentExited" || event.type === "agentBusyChanged" || event.type === "delegationReady" || - event.type === "orchestratorRequestProcessed" + event.type === "orchestratorRequestProcessed" || + event.type === "backgroundTaskChanged" || + event.type === "agentInboxChanged" || + event.type === "agentWakeChanged" ) { void refresh(); }