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

@ -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),
};
}