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:
@ -1,9 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentTicketState,
|
AgentTicketState,
|
||||||
AgentWorkState,
|
AgentWorkState,
|
||||||
|
BackgroundCompletion,
|
||||||
ConversationPreviewStatus,
|
ConversationPreviewStatus,
|
||||||
ConversationTurnWorkPreview,
|
ConversationTurnWorkPreview,
|
||||||
ConversationWorkSummary,
|
ConversationWorkSummary,
|
||||||
|
InboxItem,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
TicketWorkSource,
|
TicketWorkSource,
|
||||||
TicketWorkStatus,
|
TicketWorkStatus,
|
||||||
@ -24,10 +26,22 @@ function numberValue(value: unknown, fallback = 0): number {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
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[] {
|
function arrayValue(value: unknown): unknown[] {
|
||||||
return Array.isArray(value) ? value : [];
|
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 {
|
function normalizeSource(value: unknown): TicketWorkSource {
|
||||||
if (!isRecord(value)) return { kind: "human" };
|
if (!isRecord(value)) return { kind: "human" };
|
||||||
if (value.kind === "agent") {
|
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 {
|
function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
||||||
const agent = isRecord(value) ? value : {};
|
const agent = isRecord(value) ? value : {};
|
||||||
const liveKind: "pty" | "structured" =
|
const liveKind: "pty" | "structured" =
|
||||||
@ -78,13 +143,26 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
agentId: stringValue(agent.agentId, `legacy-agent-${index}`),
|
agentId,
|
||||||
name: stringValue(agent.name, "Unnamed agent"),
|
name: stringValue(agent.name, "Unnamed agent"),
|
||||||
profileId: stringValue(agent.profileId, "unknown"),
|
profileId: stringValue(agent.profileId, "unknown"),
|
||||||
...(live ? { live } : {}),
|
...(live ? { live } : {}),
|
||||||
busy: normalizeBusy(agent.busy),
|
busy: normalizeBusy(agent.busy),
|
||||||
tickets: arrayValue(agent.tickets).map(normalizeTicket),
|
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(
|
function normalizeConversation(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
index: number,
|
index: number,
|
||||||
@ -138,8 +212,33 @@ function normalizeConversation(
|
|||||||
|
|
||||||
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
|
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
|
||||||
const state = isRecord(value) ? value : {};
|
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 {
|
return {
|
||||||
agents: arrayValue(state.agents).map(normalizeAgent),
|
agents,
|
||||||
conversations: arrayValue(state.conversations).map(normalizeConversation),
|
conversations: arrayValue(state.conversations).map(normalizeConversation),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,26 @@ export type DomainEvent =
|
|||||||
| { type: "agentExited"; agentId: string; code: number }
|
| { type: "agentExited"; agentId: string; code: number }
|
||||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
| { 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
|
* A delegation is ready to be injected into the agent's native terminal
|
||||||
@ -170,6 +190,31 @@ export interface AgentTicketState {
|
|||||||
taskLen: number;
|
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. */
|
/** Availability of a compact conversation summary in the work-state read-model. */
|
||||||
export type ConversationPreviewStatus =
|
export type ConversationPreviewStatus =
|
||||||
| "ready"
|
| "ready"
|
||||||
@ -205,6 +250,9 @@ export interface AgentWorkState {
|
|||||||
live?: LiveWorkSession;
|
live?: LiveWorkSession;
|
||||||
busy: WorkBusyState;
|
busy: WorkBusyState;
|
||||||
tickets: AgentTicketState[];
|
tickets: AgentTicketState[];
|
||||||
|
inboxDepth?: number;
|
||||||
|
inbox?: InboxItem[];
|
||||||
|
backgroundTasks?: BackgroundCompletion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal read-only live-state surface for a project. */
|
/** Minimal read-only live-state surface for a project. */
|
||||||
|
|||||||
@ -317,6 +317,16 @@ function LeafView({
|
|||||||
? workState?.agents.find((row) => row.agentId === agentId)
|
? workState?.agents.find((row) => row.agentId === agentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
|
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 busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
|
||||||
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
|
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
|
||||||
const historyConversationId =
|
const historyConversationId =
|
||||||
@ -661,7 +671,7 @@ function LeafView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{agentId && (busyByWorkState || historyConversationId) && (
|
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
@ -699,6 +709,60 @@ function LeafView({
|
|||||||
: "Busy"}
|
: "Busy"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!busyByWorkState && inboxDepth > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${inboxDepth} pending message${inboxDepth === 1 ? "" : "s"}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: 180,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "rgba(91, 155, 213, 0.14)",
|
||||||
|
color: "var(--color-primary, #5b9bd5)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Queued · {inboxDepth}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{busyByWorkState && inboxDepth > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${inboxDepth} queued message${inboxDepth === 1 ? "" : "s"}`}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "rgba(91, 155, 213, 0.14)",
|
||||||
|
color: "var(--color-primary, #5b9bd5)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Queued {inboxDepth}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{completedBackgroundTasks.length > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${completedBackgroundTasks.length} background task result${completedBackgroundTasks.length === 1 ? "" : "s"}`}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
border: "1px solid var(--color-success, #57b36a)",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "rgba(87, 179, 106, 0.14)",
|
||||||
|
color: "var(--color-success, #57b36a)",
|
||||||
|
padding: "1px 6px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Task done {completedBackgroundTasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{busyByWorkState && (
|
{busyByWorkState && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { LayoutInfo } from "@/domain";
|
import type { DomainEvent, LayoutInfo } from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
@ -69,6 +69,26 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
|||||||
{ id: "git", label: "Git" },
|
{ 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() {
|
export function ProjectsView() {
|
||||||
const vm = useProjects();
|
const vm = useProjects();
|
||||||
const { system } = useGateways();
|
const { system } = useGateways();
|
||||||
@ -86,6 +106,7 @@ export function ProjectsView() {
|
|||||||
const [viewerConversationId, setViewerConversationId] = useState<
|
const [viewerConversationId, setViewerConversationId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
|
|
||||||
@ -99,6 +120,32 @@ export function ProjectsView() {
|
|||||||
setViewerConversationId(null);
|
setViewerConversationId(null);
|
||||||
}, [active?.id]);
|
}, [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 activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||||
|
|
||||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||||
@ -394,6 +441,32 @@ export function ProjectsView() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import { Component, type ReactNode, useState } from "react";
|
|||||||
import type {
|
import type {
|
||||||
AgentTicketState,
|
AgentTicketState,
|
||||||
AgentWorkState,
|
AgentWorkState,
|
||||||
|
BackgroundCompletion,
|
||||||
ConversationPreviewStatus,
|
ConversationPreviewStatus,
|
||||||
ConversationWorkSummary,
|
ConversationWorkSummary,
|
||||||
|
InboxItem,
|
||||||
LeafCell,
|
LeafCell,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -85,6 +87,30 @@ function formatStatus(status: AgentTicketState["status"]): string {
|
|||||||
return status === "inProgress" ? "In progress" : "Queued";
|
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 {
|
function requesterLabel(ticket: AgentTicketState): string {
|
||||||
if (ticket.source.kind === "human") return "Human";
|
if (ticket.source.kind === "human") return "Human";
|
||||||
const id = shortTicket(ticket.source.agentId);
|
const id = shortTicket(ticket.source.agentId);
|
||||||
@ -278,6 +304,92 @@ function TicketRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InboxRow({ item }: { item: InboxItem }) {
|
||||||
|
return (
|
||||||
|
<li className="min-w-0 text-xs text-muted">
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium text-muted">
|
||||||
|
{item.kind}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 break-words">{item.body}</span>
|
||||||
|
<code
|
||||||
|
title={item.id}
|
||||||
|
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
{shortTicket(item.id)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="min-w-0 text-xs text-muted">
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
||||||
|
taskStatusClass(task.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatTaskStatus(task.status)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-content">{task.kind}</span>
|
||||||
|
{task.exitCode !== null && (
|
||||||
|
<span className="text-muted"> · exit {task.exitCode}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canCancel}
|
||||||
|
title="Backend command not exposed yet"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!hasOutput}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canRetry}
|
||||||
|
title="Backend command not exposed yet"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<code
|
||||||
|
title={task.taskId}
|
||||||
|
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
{shortTicket(task.taskId)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{open && hasOutput && (
|
||||||
|
<pre
|
||||||
|
aria-label={`task ${shortTicket(task.taskId)} output`}
|
||||||
|
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
{task.stdoutTail ? `stdout\n${task.stdoutTail}` : ""}
|
||||||
|
{task.stdoutTail && task.stderrTail ? "\n\n" : ""}
|
||||||
|
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AgentRow({
|
function AgentRow({
|
||||||
agent,
|
agent,
|
||||||
conversations,
|
conversations,
|
||||||
@ -302,6 +414,13 @@ function AgentRow({
|
|||||||
const busyState = agent.busy ?? { state: "idle" };
|
const busyState = agent.busy ?? { state: "idle" };
|
||||||
const busy = busyState.state === "busy";
|
const busy = busyState.state === "busy";
|
||||||
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
|
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 visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
|
||||||
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
||||||
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
||||||
@ -390,6 +509,14 @@ function AgentRow({
|
|||||||
>
|
>
|
||||||
{busy ? "Busy" : "Idle"}
|
{busy ? "Busy" : "Idle"}
|
||||||
</span>
|
</span>
|
||||||
|
{inboxDepth > 0 && (
|
||||||
|
<span
|
||||||
|
aria-label={`${agent.name} queue depth ${inboxDepth}`}
|
||||||
|
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
Queue {inboxDepth}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{busyState.state === "busy" && (
|
{busyState.state === "busy" && (
|
||||||
<code
|
<code
|
||||||
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
|
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
|
||||||
@ -460,6 +587,33 @@ function AgentRow({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{inbox.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Inbox
|
||||||
|
</p>
|
||||||
|
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
|
||||||
|
{inbox.map((item) => (
|
||||||
|
<InboxRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{backgroundTasks.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Background tasks
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
aria-label={`${agent.name} background tasks`}
|
||||||
|
className="mt-1 flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
{backgroundTasks.map((task) => (
|
||||||
|
<BackgroundTaskRow key={task.taskId} task={task} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,10 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode
|
|||||||
event.type === "agentExited" ||
|
event.type === "agentExited" ||
|
||||||
event.type === "agentBusyChanged" ||
|
event.type === "agentBusyChanged" ||
|
||||||
event.type === "delegationReady" ||
|
event.type === "delegationReady" ||
|
||||||
event.type === "orchestratorRequestProcessed"
|
event.type === "orchestratorRequestProcessed" ||
|
||||||
|
event.type === "backgroundTaskChanged" ||
|
||||||
|
event.type === "agentInboxChanged" ||
|
||||||
|
event.type === "agentWakeChanged"
|
||||||
) {
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user