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 {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{agentId && (busyByWorkState || historyConversationId) && (
|
||||
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@ -699,6 +709,60 @@ function LeafView({
|
||||
: "Busy"}
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import type { DomainEvent, LayoutInfo } from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
@ -69,6 +69,26 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ 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() {
|
||||
const vm = useProjects();
|
||||
const { system } = useGateways();
|
||||
@ -86,6 +106,7 @@ export function ProjectsView() {
|
||||
const [viewerConversationId, setViewerConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
@ -99,6 +120,32 @@ export function ProjectsView() {
|
||||
setViewerConversationId(null);
|
||||
}, [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 canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||
@ -394,6 +441,32 @@ export function ProjectsView() {
|
||||
)}
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<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({
|
||||
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"}
|
||||
</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" && (
|
||||
<code
|
||||
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
|
||||
@ -460,6 +587,33 @@ function AgentRow({
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user