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:
@ -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 ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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,46 +32,105 @@ function requesterLabel(ticket: AgentTicketState): string {
|
|||||||
: `Agent ${id}`;
|
: `Agent ${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketRow({ ticket }: { ticket: AgentTicketState }) {
|
function summaryLabel(status: ConversationPreviewStatus): string {
|
||||||
const truncated = ticket.taskLen > ticket.taskPreview.length;
|
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 (
|
return (
|
||||||
<li className="flex min-w-0 items-start gap-2 text-xs text-muted">
|
<div className="mt-1 flex min-w-0 items-start gap-2 pl-1 text-xs text-muted">
|
||||||
<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",
|
||||||
ticket.status === "inProgress"
|
summary.status === "ready"
|
||||||
? "bg-warning/15 text-warning"
|
? "bg-success/10 text-success"
|
||||||
: "bg-raised text-muted",
|
: "bg-raised text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
#{ticket.position + 1} {formatStatus(ticket.status)}
|
{summaryLabel(summary.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 break-words">{text}</span>
|
||||||
<span className="font-medium text-content">{requesterLabel(ticket)}</span>
|
</div>
|
||||||
<span className="text-muted"> · </span>
|
);
|
||||||
<span className="break-words">{ticket.taskPreview}</span>
|
}
|
||||||
{truncated && (
|
|
||||||
<span
|
function TicketRow({
|
||||||
aria-label={`${ticket.taskLen - ticket.taskPreview.length} more characters`}
|
ticket,
|
||||||
className="text-muted"
|
summary,
|
||||||
>
|
}: {
|
||||||
{" "}
|
ticket: AgentTicketState;
|
||||||
+{ticket.taskLen - ticket.taskPreview.length}
|
summary?: ConversationWorkSummary;
|
||||||
</span>
|
}) {
|
||||||
)}
|
const truncated = ticket.taskLen > ticket.taskPreview.length;
|
||||||
</span>
|
return (
|
||||||
<code
|
<li className="min-w-0 text-xs text-muted">
|
||||||
aria-label={`ticket ${shortTicket(ticket.ticketId)}`}
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
title={ticket.ticketId}
|
<span
|
||||||
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
|
className={cn(
|
||||||
>
|
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
||||||
{shortTicket(ticket.ticketId)}
|
ticket.status === "inProgress"
|
||||||
</code>
|
? "bg-warning/15 text-warning"
|
||||||
|
: "bg-raised text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
#{ticket.position + 1} {formatStatus(ticket.status)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-content">{requesterLabel(ticket)}</span>
|
||||||
|
<span className="text-muted"> · </span>
|
||||||
|
<span className="break-words">{ticket.taskPreview}</span>
|
||||||
|
{truncated && (
|
||||||
|
<span
|
||||||
|
aria-label={`${ticket.taskLen - ticket.taskPreview.length} more characters`}
|
||||||
|
className="text-muted"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
+{ticket.taskLen - ticket.taskPreview.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
aria-label={`ticket ${shortTicket(ticket.ticketId)}`}
|
||||||
|
title={ticket.ticketId}
|
||||||
|
className="mt-0.5 shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
{shortTicket(ticket.ticketId)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user