feat(workstate): UI des résumés de conversation (Lot C frontend)

Affiche les résumés de conversation dans ProjectWorkStatePanel à partir du
DTO camelCase : type de domaine et adaptateur mock alignés, panneau enrichi.

Tests verts : workstate.test.tsx + projects.test.tsx (2 fichiers / 19),
tsc --noEmit OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:29:01 +02:00
parent e9edadca50
commit c50622e944
4 changed files with 292 additions and 32 deletions

View File

@ -1662,7 +1662,9 @@ export class MockWorkStateGateway implements WorkStateGateway {
} }
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> { async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
return structuredClone(this.states.get(projectId) ?? { agents: [] }); return structuredClone(
this.states.get(projectId) ?? { agents: [], conversations: [] },
);
} }
} }
@ -1670,6 +1672,7 @@ type LegacyProjectWorkState = {
agents: Array<Omit<ProjectWorkState["agents"][number], "tickets"> & { agents: Array<Omit<ProjectWorkState["agents"][number], "tickets"> & {
tickets?: ProjectWorkState["agents"][number]["tickets"]; tickets?: ProjectWorkState["agents"][number]["tickets"];
}>; }>;
conversations?: ProjectWorkState["conversations"];
}; };
function normalizeProjectWorkState( function normalizeProjectWorkState(
@ -1680,6 +1683,7 @@ function normalizeProjectWorkState(
...structuredClone(agent), ...structuredClone(agent),
tickets: structuredClone(agent.tickets ?? []), tickets: structuredClone(agent.tickets ?? []),
})), })),
conversations: structuredClone(state.conversations ?? []),
}; };
} }

View File

@ -169,6 +169,33 @@ export interface AgentTicketState {
taskLen: number; 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. */ /** One agent row in the project work-state read-model. */
export interface AgentWorkState { export interface AgentWorkState {
agentId: string; agentId: string;
@ -182,6 +209,7 @@ export interface AgentWorkState {
/** Minimal read-only live-state surface for a project. */ /** Minimal read-only live-state surface for a project. */
export interface ProjectWorkState { export interface ProjectWorkState {
agents: AgentWorkState[]; agents: AgentWorkState[];
conversations: ConversationWorkSummary[];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -3,7 +3,12 @@
* and idle/busy state from the backend read-model, plus the current input queue. * 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 { Button, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState"; import { useProjectWorkState } from "./useProjectWorkState";
@ -27,10 +32,61 @@ function requesterLabel(ticket: AgentTicketState): string {
: `Agent ${id}`; : `Agent ${id}`;
} }
function TicketRow({ ticket }: { ticket: AgentTicketState }) { 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 (
<div className="mt-1 flex min-w-0 items-start gap-2 pl-1 text-xs text-muted">
<span
className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
summary.status === "ready"
? "bg-success/10 text-success"
: "bg-raised text-muted",
)}
>
{summaryLabel(summary.status)}
</span>
<span className="min-w-0 break-words">{text}</span>
</div>
);
}
function TicketRow({
ticket,
summary,
}: {
ticket: AgentTicketState;
summary?: ConversationWorkSummary;
}) {
const truncated = ticket.taskLen > ticket.taskPreview.length; const truncated = ticket.taskLen > ticket.taskPreview.length;
return ( return (
<li className="flex min-w-0 items-start gap-2 text-xs text-muted"> <li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-start gap-2">
<span <span
className={cn( className={cn(
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium", "mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
@ -62,11 +118,19 @@ function TicketRow({ ticket }: { ticket: AgentTicketState }) {
> >
{shortTicket(ticket.ticketId)} {shortTicket(ticket.ticketId)}
</code> </code>
</div>
{summary && <ConversationSummaryLine summary={summary} />}
</li> </li>
); );
} }
function AgentRow({ agent }: { agent: AgentWorkState }) { function AgentRow({
agent,
conversations,
}: {
agent: AgentWorkState;
conversations: Map<string, ConversationWorkSummary>;
}) {
const live = agent.live !== undefined; const live = agent.live !== undefined;
const busy = agent.busy.state === "busy"; const busy = agent.busy.state === "busy";
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position); 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" className="mt-2 flex flex-col gap-1"
> >
{tickets.map((ticket) => ( {tickets.map((ticket) => (
<TicketRow key={ticket.ticketId} ticket={ticket} /> <TicketRow
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
/>
))} ))}
</ul> </ul>
)} )}
@ -128,6 +196,12 @@ function AgentRow({ agent }: { agent: AgentWorkState }) {
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) { export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
const vm = useProjectWorkState(projectId); const vm = useProjectWorkState(projectId);
const agents = vm.state?.agents ?? []; const agents = vm.state?.agents ?? [];
const conversations = new Map(
(vm.state?.conversations ?? []).map((summary) => [
summary.conversationId,
summary,
]),
);
return ( return (
<Panel <Panel
@ -161,7 +235,11 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps)
) : ( ) : (
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{agents.map((agent) => ( {agents.map((agent) => (
<AgentRow key={agent.agentId} agent={agent} /> <AgentRow
key={agent.agentId}
agent={agent}
conversations={conversations}
/>
))} ))}
</ul> </ul>
)} )}

View File

@ -189,9 +189,159 @@ describe("ProjectWorkStatePanel", () => {
tickets: [], 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 () => { it("refreshes when a relevant domain event fires", async () => {
const workState = new MockWorkStateGateway(); const workState = new MockWorkStateGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();