diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 41ba72f..96b380b 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1662,7 +1662,9 @@ export class MockWorkStateGateway implements WorkStateGateway { } async getProjectWorkState(projectId: string): Promise { - return structuredClone(this.states.get(projectId) ?? { agents: [] }); + return structuredClone( + this.states.get(projectId) ?? { agents: [], conversations: [] }, + ); } } @@ -1670,6 +1672,7 @@ type LegacyProjectWorkState = { agents: Array & { tickets?: ProjectWorkState["agents"][number]["tickets"]; }>; + conversations?: ProjectWorkState["conversations"]; }; function normalizeProjectWorkState( @@ -1680,6 +1683,7 @@ function normalizeProjectWorkState( ...structuredClone(agent), tickets: structuredClone(agent.tickets ?? []), })), + conversations: structuredClone(state.conversations ?? []), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 06cb66a..2319b63 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -169,6 +169,33 @@ export interface AgentTicketState { taskLen: number; } +/** Availability of a compact conversation summary in the work-state read-model. */ +export type ConversationPreviewStatus = + | "ready" + | "missing" + | "partial" + | "unavailable"; + +/** Recent turn preview attached to a conversation summary. */ +export interface ConversationTurnWorkPreview { + role: "prompt" | "response" | "toolActivity"; + source: TicketWorkSource; + atMs: number; + textPreview: string; + textLen: number; +} + +/** Compact thread context joined to tickets by conversation id. */ +export interface ConversationWorkSummary { + conversationId: string; + status: ConversationPreviewStatus; + objectivePreview: string | null; + summaryPreview: string | null; + summaryLen: number; + upTo: string | null; + recentTurns: ConversationTurnWorkPreview[]; +} + /** One agent row in the project work-state read-model. */ export interface AgentWorkState { agentId: string; @@ -182,6 +209,7 @@ export interface AgentWorkState { /** Minimal read-only live-state surface for a project. */ export interface ProjectWorkState { agents: AgentWorkState[]; + conversations: ConversationWorkSummary[]; } // --------------------------------------------------------------------------- diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index 969c606..0fc783c 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -3,7 +3,12 @@ * and idle/busy state from the backend read-model, plus the current input queue. */ -import type { AgentTicketState, AgentWorkState } from "@/domain"; +import type { + AgentTicketState, + AgentWorkState, + ConversationPreviewStatus, + ConversationWorkSummary, +} from "@/domain"; import { Button, Panel, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "./useProjectWorkState"; @@ -27,46 +32,105 @@ function requesterLabel(ticket: AgentTicketState): string { : `Agent ${id}`; } -function TicketRow({ ticket }: { ticket: AgentTicketState }) { - const truncated = ticket.taskLen > ticket.taskPreview.length; +function summaryLabel(status: ConversationPreviewStatus): string { + switch (status) { + case "ready": + return "Summary"; + case "missing": + return "No summary"; + case "partial": + return "Partial"; + case "unavailable": + return "Unavailable"; + } +} + +function summaryText(summary: ConversationWorkSummary): string | null { + if (summary.objectivePreview) return `Goal: ${summary.objectivePreview}`; + if (summary.summaryPreview) return summary.summaryPreview; + const lastTurn = summary.recentTurns.at(-1); + return lastTurn ? `Last: ${lastTurn.textPreview}` : null; +} + +function ConversationSummaryLine({ + summary, +}: { + summary: ConversationWorkSummary; +}) { + const text = summaryText(summary); + if (!text) return null; return ( -
  • +
    - #{ticket.position + 1} {formatStatus(ticket.status)} + {summaryLabel(summary.status)} - - {requesterLabel(ticket)} - · - {ticket.taskPreview} - {truncated && ( - - {" "} - +{ticket.taskLen - ticket.taskPreview.length} - - )} - - - {shortTicket(ticket.ticketId)} - + {text} +
    + ); +} + +function TicketRow({ + ticket, + summary, +}: { + ticket: AgentTicketState; + summary?: ConversationWorkSummary; +}) { + 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)} + +
    + {summary && }
  • ); } -function AgentRow({ agent }: { agent: AgentWorkState }) { +function AgentRow({ + agent, + conversations, +}: { + agent: AgentWorkState; + conversations: Map; +}) { const live = agent.live !== undefined; const busy = agent.busy.state === "busy"; const tickets = [...agent.tickets].sort((a, b) => a.position - b.position); @@ -117,7 +181,11 @@ function AgentRow({ agent }: { agent: AgentWorkState }) { className="mt-2 flex flex-col gap-1" > {tickets.map((ticket) => ( - + ))} )} @@ -128,6 +196,12 @@ function AgentRow({ agent }: { agent: AgentWorkState }) { export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) { const vm = useProjectWorkState(projectId); const agents = vm.state?.agents ?? []; + const conversations = new Map( + (vm.state?.conversations ?? []).map((summary) => [ + summary.conversationId, + summary, + ]), + ); return ( {agents.map((agent) => ( - + ))} )} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 003dc27..0063029 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -189,9 +189,159 @@ describe("ProjectWorkStatePanel", () => { tickets: [], }, ], + conversations: [], }); }); + it("renders conversation objective and summary joined by conversation id", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-8", + name: "Summarizer", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-goal-abcdef", sinceMs: 123 }, + tickets: [ + { + ticketId: "ticket-goal-abcdef", + conversationId: "conversation-goal", + position: 0, + status: "inProgress", + source: { kind: "human" }, + requesterLabel: "Anthony", + taskPreview: "Continue implementation", + taskLen: 23, + }, + { + ticketId: "ticket-summary-abcdef", + conversationId: "conversation-summary", + position: 1, + status: "queued", + source: { kind: "agent", agentId: "agent-main-123456" }, + requesterLabel: "Main", + taskPreview: "Follow up", + taskLen: 9, + }, + ], + }, + ], + conversations: [ + { + conversationId: "conversation-summary", + status: "ready", + objectivePreview: null, + summaryPreview: "Previous turn established the adapter boundary.", + summaryLen: 47, + upTo: "turn-2", + recentTurns: [], + }, + { + conversationId: "conversation-goal", + status: "ready", + objectivePreview: "Ship the Work panel summaries", + summaryPreview: "This should be lower priority than the goal.", + summaryLen: 48, + upTo: "turn-1", + recentTurns: [], + }, + ], + }); + + renderPanel(workState); + + const list = await screen.findByLabelText("Summarizer tickets"); + expect(within(list).getAllByText("Summary")).toHaveLength(2); + expect( + within(list).getByText("Goal: Ship the Work panel summaries"), + ).toBeTruthy(); + expect( + within(list).getByText( + "Previous turn established the adapter boundary.", + ), + ).toBeTruthy(); + }); + + it("keeps partial and unavailable summaries compact without hiding tickets", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-9", + name: "Contextualizer", + profileId: "claude", + busy: { state: "idle" }, + tickets: [ + { + ticketId: "ticket-partial-abcdef", + conversationId: "conversation-partial", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "agent-qa-123456" }, + requesterLabel: "QA", + taskPreview: "Validate partial context", + taskLen: 24, + }, + { + ticketId: "ticket-unavailable-abcdef", + conversationId: "conversation-unavailable", + position: 1, + status: "queued", + source: { kind: "human" }, + requesterLabel: "Anthony", + taskPreview: "Validate unavailable context", + taskLen: 28, + }, + ], + }, + ], + conversations: [ + { + conversationId: "conversation-partial", + status: "partial", + objectivePreview: null, + summaryPreview: null, + summaryLen: 0, + upTo: null, + recentTurns: [ + { + role: "prompt", + source: { kind: "agent", agentId: "agent-qa-123456" }, + atMs: 100, + textPreview: "Only the latest prompt was available.", + textLen: 37, + }, + ], + }, + { + conversationId: "conversation-unavailable", + status: "unavailable", + objectivePreview: null, + summaryPreview: "Summary service unavailable right now.", + summaryLen: 38, + upTo: null, + recentTurns: [], + }, + ], + }); + + renderPanel(workState); + + const list = await screen.findByLabelText("Contextualizer tickets"); + expect(within(list).getByText("#1 In progress")).toBeTruthy(); + expect(within(list).getByText("Validate partial context")).toBeTruthy(); + expect(within(list).getByText("Partial")).toBeTruthy(); + expect( + within(list).getByText("Last: Only the latest prompt was available."), + ).toBeTruthy(); + expect(within(list).getByText("#2 Queued")).toBeTruthy(); + expect(within(list).getByText("Validate unavailable context")).toBeTruthy(); + expect(within(list).getByText("Unavailable")).toBeTruthy(); + expect( + within(list).getByText("Summary service unavailable right now."), + ).toBeTruthy(); + }); + it("refreshes when a relevant domain event fires", async () => { const workState = new MockWorkStateGateway(); const system = new MockSystemGateway();