diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 65a3b89..41ba72f 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1654,8 +1654,11 @@ export class MockWorkStateGateway implements WorkStateGateway { private states = new Map(); /** Seeds the read-model returned for a project (deterministic tests/dev). */ - _setProjectWorkState(projectId: string, state: ProjectWorkState): void { - this.states.set(projectId, structuredClone(state)); + _setProjectWorkState( + projectId: string, + state: ProjectWorkState | LegacyProjectWorkState, + ): void { + this.states.set(projectId, normalizeProjectWorkState(state)); } async getProjectWorkState(projectId: string): Promise { @@ -1663,6 +1666,23 @@ export class MockWorkStateGateway implements WorkStateGateway { } } +type LegacyProjectWorkState = { + agents: Array & { + tickets?: ProjectWorkState["agents"][number]["tickets"]; + }>; +}; + +function normalizeProjectWorkState( + state: ProjectWorkState | LegacyProjectWorkState, +): ProjectWorkState { + return { + agents: state.agents.map((agent) => ({ + ...structuredClone(agent), + tickets: structuredClone(agent.tickets ?? []), + })), + }; +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 06c9adb..06cb66a 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -149,6 +149,26 @@ export type WorkBusyState = | { state: "idle" } | { state: "busy"; ticket: string; sinceMs: number }; +/** FIFO status for an input/ticket currently tracked by the backend queue. */ +export type TicketWorkStatus = "inProgress" | "queued"; + +/** Who produced an input/ticket in the work-state read-model. */ +export type TicketWorkSource = + | { kind: "human" } + | { kind: "agent"; agentId: string }; + +/** One queued or in-progress input/ticket for an agent. */ +export interface AgentTicketState { + ticketId: string; + conversationId: string; + position: number; + status: TicketWorkStatus; + source: TicketWorkSource; + requesterLabel: string; + taskPreview: string; + taskLen: number; +} + /** One agent row in the project work-state read-model. */ export interface AgentWorkState { agentId: string; @@ -156,6 +176,7 @@ export interface AgentWorkState { profileId: string; live?: LiveWorkSession; busy: WorkBusyState; + tickets: AgentTicketState[]; } /** Minimal read-only live-state surface for a project. */ diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index 53629d9..969c606 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -1,9 +1,9 @@ /** * Read-only project work-state panel: one row per agent, showing live/offline - * and idle/busy state from the backend read-model. + * and idle/busy state from the backend read-model, plus the current input queue. */ -import type { AgentWorkState } from "@/domain"; +import type { AgentTicketState, AgentWorkState } from "@/domain"; import { Button, Panel, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "./useProjectWorkState"; @@ -15,48 +15,112 @@ function shortTicket(ticket: string): string { return ticket.length <= 8 ? ticket : ticket.slice(0, 8); } +function formatStatus(status: AgentTicketState["status"]): string { + return status === "inProgress" ? "In progress" : "Queued"; +} + +function requesterLabel(ticket: AgentTicketState): string { + if (ticket.source.kind === "human") return "Human"; + const id = shortTicket(ticket.source.agentId); + return ticket.requesterLabel.trim() + ? `${ticket.requesterLabel} (${id})` + : `Agent ${id}`; +} + +function TicketRow({ ticket }: { ticket: AgentTicketState }) { + const truncated = ticket.taskLen > ticket.taskPreview.length; + return ( +
  • + + #{ticket.position + 1} {formatStatus(ticket.status)} + + + {requesterLabel(ticket)} + ยท + {ticket.taskPreview} + {truncated && ( + + {" "} + +{ticket.taskLen - ticket.taskPreview.length} + + )} + + + {shortTicket(ticket.ticketId)} + +
  • + ); +} + function AgentRow({ agent }: { agent: AgentWorkState }) { const live = agent.live !== undefined; const busy = agent.busy.state === "busy"; + const tickets = [...agent.tickets].sort((a, b) => a.position - b.position); return ( -
  • - - - {agent.name} +
  • +
    + + + {agent.name} + + {agent.profileId} - {agent.profileId} - - - - {live ? "Live" : "Offline"} - - - {busy ? "Busy" : "Idle"} - - {agent.busy.state === "busy" && ( - + - {shortTicket(agent.busy.ticket)} - - )} - + {live ? "Live" : "Offline"} + + + {busy ? "Busy" : "Idle"} + + {agent.busy.state === "busy" && ( + + {shortTicket(agent.busy.ticket)} + + )} + +
    + {tickets.length > 0 && ( +
      + {tickets.map((ticket) => ( + + ))} +
    + )}
  • ); } diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts index 035e663..4c0ffd9 100644 --- a/frontend/src/features/workstate/useProjectWorkState.ts +++ b/frontend/src/features/workstate/useProjectWorkState.ts @@ -55,6 +55,7 @@ export function useProjectWorkState(projectId: string): ProjectWorkStateViewMode event.type === "agentLaunched" || event.type === "agentExited" || event.type === "agentBusyChanged" || + event.type === "delegationReady" || event.type === "orchestratorRequestProcessed" ) { void refresh(); diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 20dec69..003dc27 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; @@ -49,6 +49,7 @@ describe("ProjectWorkStatePanel", () => { expect(await screen.findByText("Planner")).toBeTruthy(); expect(screen.getByText("Offline")).toBeTruthy(); expect(screen.getByText("Idle")).toBeTruthy(); + expect(screen.queryByLabelText("Planner tickets")).toBeNull(); }); it("renders a live busy agent with a short ticket", async () => { @@ -81,6 +82,116 @@ describe("ProjectWorkStatePanel", () => { expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy(); }); + it("renders an in-progress human ticket", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-4", + name: "Implementer", + profileId: "codex", + busy: { + state: "busy", + ticket: "ticket-in-progress-abcdef", + sinceMs: 789, + }, + tickets: [ + { + ticketId: "ticket-in-progress-abcdef", + conversationId: "conversation-a", + position: 0, + status: "inProgress", + source: { kind: "human" }, + requesterLabel: "Anthony", + taskPreview: "Wire the workstate queue UI", + taskLen: 36, + }, + ], + }, + ], + }); + + renderPanel(workState); + + const list = await screen.findByLabelText("Implementer tickets"); + expect(within(list).getByText("#1 In progress")).toBeTruthy(); + expect(within(list).getByText("Human")).toBeTruthy(); + expect(within(list).getByText("Wire the workstate queue UI")).toBeTruthy(); + expect(within(list).getByLabelText("ticket ticket-i")).toBeTruthy(); + expect(within(list).getByLabelText("9 more characters")).toBeTruthy(); + }); + + it("renders two tickets in FIFO order", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-5", + name: "Reviewer", + profileId: "claude", + busy: { state: "idle" }, + tickets: [ + { + ticketId: "ticket-second-abcdef", + conversationId: "conversation-b", + position: 1, + status: "queued", + source: { kind: "agent", agentId: "agent-main-123456" }, + requesterLabel: "Main", + taskPreview: "Then review the result", + taskLen: 22, + }, + { + ticketId: "ticket-first-abcdef", + conversationId: "conversation-a", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "agent-qa-123456" }, + requesterLabel: "QA", + taskPreview: "Check behavior", + taskLen: 14, + }, + ], + }, + ], + }); + + renderPanel(workState); + + const list = await screen.findByLabelText("Reviewer tickets"); + expect(within(list).getByText("QA (agent-qa)")).toBeTruthy(); + expect(within(list).getByText("Main (agent-ma)")).toBeTruthy(); + expect(list.textContent?.indexOf("#1 In progress")).toBeLessThan( + list.textContent?.indexOf("#2 Queued") ?? -1, + ); + }); + + it("normalizes mock work state without tickets", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-6", + name: "Legacy", + profileId: "gemini", + busy: { state: "idle" }, + }, + ], + }); + + await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({ + agents: [ + { + agentId: "agent-6", + name: "Legacy", + profileId: "gemini", + busy: { state: "idle" }, + tickets: [], + }, + ], + }); + }); + it("refreshes when a relevant domain event fires", async () => { const workState = new MockWorkStateGateway(); const system = new MockSystemGateway(); @@ -108,4 +219,46 @@ describe("ProjectWorkStatePanel", () => { expect(await screen.findByText("Responder")).toBeTruthy(); await waitFor(() => expect(spy).toHaveBeenCalled()); }); + + it("refreshes when delegationReady fires", async () => { + const workState = new MockWorkStateGateway(); + const system = new MockSystemGateway(); + renderPanel(workState, system); + + await screen.findByText("No agent work state."); + const spy = vi.spyOn(workState, "getProjectWorkState"); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-7", + name: "Delegatee", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-ready-123456", sinceMs: 123 }, + tickets: [ + { + ticketId: "ticket-ready-123456", + conversationId: "conversation-ready", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "agent-main-123456" }, + requesterLabel: "Main", + taskPreview: "Handle this delegated task", + taskLen: 26, + }, + ], + }, + ], + }); + + system.emit({ + type: "delegationReady", + agentId: "agent-7", + ticket: "ticket-ready-123456", + text: "Handle this delegated task", + }); + + expect(await screen.findByText("Delegatee")).toBeTruthy(); + expect(screen.getByText("#1 In progress")).toBeTruthy(); + await waitFor(() => expect(spy).toHaveBeenCalled()); + }); });