diff --git a/frontend/src/features/conversations/ConversationViewer.test.tsx b/frontend/src/features/conversations/ConversationViewer.test.tsx index e3ae617f..ef387de3 100644 --- a/frontend/src/features/conversations/ConversationViewer.test.tsx +++ b/frontend/src/features/conversations/ConversationViewer.test.tsx @@ -41,6 +41,7 @@ function renderViewer(opts: { conversation: ConversationGateway; workState?: MockWorkStateGateway; onClose?: () => void; + onOpenConversation?: (conversationId: string) => void; }) { const workState = opts.workState ?? new MockWorkStateGateway(); const gateways = { @@ -59,6 +60,7 @@ function renderViewer(opts: { projectId={P} conversationId={C} onClose={onClose} + onOpenConversation={opts.onOpenConversation} />, ), ), @@ -132,6 +134,116 @@ describe("ConversationViewer", () => { expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy(); }); + it("distinguishes an active current thread whose transcript has no visible turns yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [ + { + ticketId: "ticket-1", + conversationId: C, + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "main" }, + requesterLabel: "Main", + taskPreview: "Diagnostiquer le bug", + taskLen: 20, + }, + ], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect( + await screen.findByText("Agent actif, aucun message visible pour le moment."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Ouvrir le fil actif" })).toBeNull(); + }); + + it("offers to open the active canonical thread when another conversation is busy", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [ + { + ticketId: "ticket-1", + conversationId: "conv-active", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "main" }, + requesterLabel: "Main", + taskPreview: "Conversation active", + taskLen: 19, + }, + ], + }, + ], + }); + const onOpenConversation = vi.fn(); + renderViewer({ + conversation: seeded([]), + workState, + onOpenConversation, + }); + + expect(await screen.findByText("Aucun tour sur ce fil.")).toBeTruthy(); + expect(screen.getByText(/L'activité en cours semble être sur un autre fil/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir le fil actif" })); + expect(onOpenConversation).toHaveBeenCalledWith("conv-active"); + }); + + it("distinguishes a work-state summary that has not reached transcript pagination yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [], + conversations: [ + { + conversationId: C, + status: "partial", + objectivePreview: "Analyser la désynchronisation", + summaryPreview: null, + summaryLen: 0, + upTo: null, + recentTurns: [], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect( + await screen.findByText("Activité détectée, transcript pas encore synchronisé."), + ).toBeTruthy(); + }); + + it("distinguishes a busy agent with no visible thread binding yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect(await screen.findByText("Agent actif sans message visible.")).toBeTruthy(); + }); + it("shows the loading state while the first page is in flight", async () => { const gated: ConversationGateway = { readPage: () => new Promise(() => {}) }; renderViewer({ conversation: gated }); diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx index 7068b6c7..ffd54f01 100644 --- a/frontend/src/features/conversations/ConversationViewer.tsx +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -10,7 +10,14 @@ import { useMemo, useState } from "react"; -import type { TurnSource, TurnView } from "@/domain"; +import type { + AgentWorkState, + BackgroundCompletion, + ConversationWorkSummary, + ProjectWorkState, + TurnSource, + TurnView, +} from "@/domain"; import { Button, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "@/features/workstate"; import { useConversationThread } from "./useConversationThread"; @@ -19,6 +26,7 @@ export interface ConversationViewerProps { projectId: string; conversationId: string; onClose: () => void; + onOpenConversation?: (conversationId: string) => void; } function shortId(id: string): string { @@ -50,6 +58,118 @@ function roleLabel(role: TurnView["role"]): string { } } +interface ConversationActivity { + conversationId: string; + label: string; + priority: number; +} + +interface EmptyThreadState { + message: string; + detail?: string; + action?: { + conversationId: string; + label: string; + }; +} + +function taskIsActive(status: BackgroundCompletion["status"]): boolean { + return status === "running" || status === "pending" || status === "cancelling"; +} + +function agentLabel(agent: AgentWorkState): string { + return agent.name.trim() || `Agent ${shortId(agent.agentId)}`; +} + +function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean { + return Boolean( + summary.objectivePreview || + summary.summaryPreview || + (summary.recentTurns ?? []).length > 0, + ); +} + +function activeConversations(state: ProjectWorkState | null): ConversationActivity[] { + const out: ConversationActivity[] = []; + for (const agent of state?.agents ?? []) { + for (const ticket of agent.tickets ?? []) { + out.push({ + conversationId: ticket.conversationId, + label: `${agentLabel(agent)} · ${ticket.taskPreview || "ticket actif"}`, + priority: ticket.status === "inProgress" ? 0 : 1, + }); + } + for (const task of agent.backgroundTasks ?? []) { + if (!task.conversationId || !taskIsActive(task.status)) continue; + out.push({ + conversationId: task.conversationId, + label: `${agentLabel(agent)} · ${task.kind}`, + priority: 2, + }); + } + } + return out.sort( + (a, b) => + a.priority - b.priority || + a.conversationId.localeCompare(b.conversationId), + ); +} + +function emptyThreadState( + conversationId: string, + state: ProjectWorkState | null, +): EmptyThreadState { + const summaries = state?.conversations ?? []; + const currentSummary = summaries.find( + (summary) => summary.conversationId === conversationId, + ); + const activities = activeConversations(state); + const currentActivity = activities.find( + (activity) => activity.conversationId === conversationId, + ); + if (currentActivity) { + return { + message: "Agent actif, aucun message visible pour le moment.", + detail: + "Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.", + }; + } + if (currentSummary && summaryHasVisibleContent(currentSummary)) { + return { + message: "Activité détectée, transcript pas encore synchronisé.", + detail: + "Le résumé Work state connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.", + }; + } + + const otherActivity = activities.find( + (activity) => activity.conversationId !== conversationId, + ); + if (otherActivity) { + return { + message: "Aucun tour sur ce fil.", + detail: `L'activité en cours semble être sur un autre fil: ${otherActivity.label}.`, + action: { + conversationId: otherActivity.conversationId, + label: "Ouvrir le fil actif", + }, + }; + } + + const busyAgent = (state?.agents ?? []).find( + (agent) => agent.busy?.state === "busy", + ); + if (busyAgent) { + return { + message: "Agent actif sans message visible.", + detail: + "Work state signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.", + }; + } + + return { message: "Aucun tour pour le moment." }; +} + /** One collapsible tool-activity turn (dimmed, folded by default). */ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) { const [open, setOpen] = useState(false); @@ -87,6 +207,7 @@ export function ConversationViewer({ projectId, conversationId, onClose, + onOpenConversation, }: ConversationViewerProps) { const vm = useConversationThread(projectId, conversationId); // Reuse the already-loaded work-state inventory to resolve agent names. @@ -126,6 +247,11 @@ export function ConversationViewer({ return "Conversation"; }, [parties]); + const emptyState = useMemo( + () => emptyThreadState(conversationId, work.state), + [conversationId, work.state], + ); + function sideOf(source: TurnSource): "left" | "right" { return parties.meta.get(partyKey(source))?.side ?? "left"; } @@ -191,7 +317,24 @@ export function ConversationViewer({ Chargement du fil… ) : vm.turns.length === 0 ? ( -
Aucun tour pour le moment.
+{emptyState.message}
+ {emptyState.detail && ( +{emptyState.detail}
+ )} + {emptyState.action && onOpenConversation && ( + + )} +