fix(chat): états vides explicites et reroutage vers le fil actif quand Work state signale un agent busy — #238
ConversationViewer remplace « Aucun tour pour le moment » par un diagnostic differentié (agent actif sans tour affichable, transcript non synchronisé, activité sur un autre fil) avec action « Ouvrir le fil actif ». Le panneau Work state affiche le résumé du fil du ticket actif et ProjectsView/WebWorkspace câblent onOpenConversation.
This commit is contained in:
@ -41,6 +41,7 @@ function renderViewer(opts: {
|
|||||||
conversation: ConversationGateway;
|
conversation: ConversationGateway;
|
||||||
workState?: MockWorkStateGateway;
|
workState?: MockWorkStateGateway;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const workState = opts.workState ?? new MockWorkStateGateway();
|
const workState = opts.workState ?? new MockWorkStateGateway();
|
||||||
const gateways = {
|
const gateways = {
|
||||||
@ -59,6 +60,7 @@ function renderViewer(opts: {
|
|||||||
projectId={P}
|
projectId={P}
|
||||||
conversationId={C}
|
conversationId={C}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onOpenConversation={opts.onOpenConversation}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -132,6 +134,116 @@ describe("ConversationViewer", () => {
|
|||||||
expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy();
|
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 () => {
|
it("shows the loading state while the first page is in flight", async () => {
|
||||||
const gated: ConversationGateway = { readPage: () => new Promise(() => {}) };
|
const gated: ConversationGateway = { readPage: () => new Promise(() => {}) };
|
||||||
renderViewer({ conversation: gated });
|
renderViewer({ conversation: gated });
|
||||||
|
|||||||
@ -10,7 +10,14 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
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 { Button, Spinner, cn } from "@/shared";
|
||||||
import { useProjectWorkState } from "@/features/workstate";
|
import { useProjectWorkState } from "@/features/workstate";
|
||||||
import { useConversationThread } from "./useConversationThread";
|
import { useConversationThread } from "./useConversationThread";
|
||||||
@ -19,6 +26,7 @@ export interface ConversationViewerProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onOpenConversation?: (conversationId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortId(id: string): string {
|
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). */
|
/** One collapsible tool-activity turn (dimmed, folded by default). */
|
||||||
function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
|
function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -87,6 +207,7 @@ export function ConversationViewer({
|
|||||||
projectId,
|
projectId,
|
||||||
conversationId,
|
conversationId,
|
||||||
onClose,
|
onClose,
|
||||||
|
onOpenConversation,
|
||||||
}: ConversationViewerProps) {
|
}: ConversationViewerProps) {
|
||||||
const vm = useConversationThread(projectId, conversationId);
|
const vm = useConversationThread(projectId, conversationId);
|
||||||
// Reuse the already-loaded work-state inventory to resolve agent names.
|
// Reuse the already-loaded work-state inventory to resolve agent names.
|
||||||
@ -126,6 +247,11 @@ export function ConversationViewer({
|
|||||||
return "Conversation";
|
return "Conversation";
|
||||||
}, [parties]);
|
}, [parties]);
|
||||||
|
|
||||||
|
const emptyState = useMemo(
|
||||||
|
() => emptyThreadState(conversationId, work.state),
|
||||||
|
[conversationId, work.state],
|
||||||
|
);
|
||||||
|
|
||||||
function sideOf(source: TurnSource): "left" | "right" {
|
function sideOf(source: TurnSource): "left" | "right" {
|
||||||
return parties.meta.get(partyKey(source))?.side ?? "left";
|
return parties.meta.get(partyKey(source))?.side ?? "left";
|
||||||
}
|
}
|
||||||
@ -191,7 +317,24 @@ export function ConversationViewer({
|
|||||||
<span>Chargement du fil…</span>
|
<span>Chargement du fil…</span>
|
||||||
</div>
|
</div>
|
||||||
) : vm.turns.length === 0 ? (
|
) : vm.turns.length === 0 ? (
|
||||||
<p className="text-sm text-muted">Aucun tour pour le moment.</p>
|
<div className="max-w-xl text-sm text-muted">
|
||||||
|
<p>{emptyState.message}</p>
|
||||||
|
{emptyState.detail && (
|
||||||
|
<p className="mt-1 text-xs text-faint">{emptyState.detail}</p>
|
||||||
|
)}
|
||||||
|
{emptyState.action && onOpenConversation && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() =>
|
||||||
|
onOpenConversation(emptyState.action!.conversationId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emptyState.action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
vm.turns.map((turn) => {
|
vm.turns.map((turn) => {
|
||||||
const side = sideOf(turn.source);
|
const side = sideOf(turn.source);
|
||||||
|
|||||||
@ -923,6 +923,7 @@ export function ProjectsView() {
|
|||||||
projectId={viewerConversation.projectId}
|
projectId={viewerConversation.projectId}
|
||||||
conversationId={viewerConversation.conversationId}
|
conversationId={viewerConversation.conversationId}
|
||||||
onClose={() => setViewerConversation(null)}
|
onClose={() => setViewerConversation(null)}
|
||||||
|
onOpenConversation={openConversation}
|
||||||
/>
|
/>
|
||||||
) : activeLayoutKind === "gitGraph" ? (
|
) : activeLayoutKind === "gitGraph" ? (
|
||||||
<GitGraphView
|
<GitGraphView
|
||||||
@ -951,6 +952,7 @@ export function ProjectsView() {
|
|||||||
projectId={viewerConversation.projectId}
|
projectId={viewerConversation.projectId}
|
||||||
conversationId={viewerConversation.conversationId}
|
conversationId={viewerConversation.conversationId}
|
||||||
onClose={() => setViewerConversation(null)}
|
onClose={() => setViewerConversation(null)}
|
||||||
|
onOpenConversation={openConversation}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||||
|
|||||||
@ -541,6 +541,7 @@ function WebDesktopWorkspace({
|
|||||||
projectId={viewerConversation.projectId}
|
projectId={viewerConversation.projectId}
|
||||||
conversationId={viewerConversation.conversationId}
|
conversationId={viewerConversation.conversationId}
|
||||||
onClose={() => setViewerConversation(null)}
|
onClose={() => setViewerConversation(null)}
|
||||||
|
onOpenConversation={openConversation}
|
||||||
/>
|
/>
|
||||||
) : activeLayoutKind === "gitGraph" ? (
|
) : activeLayoutKind === "gitGraph" ? (
|
||||||
<GitGraphView
|
<GitGraphView
|
||||||
|
|||||||
@ -592,6 +592,9 @@ function AgentRow({
|
|||||||
const currentTurn =
|
const currentTurn =
|
||||||
busy || tickets.some((ticket) => ticket.status === "inProgress");
|
busy || tickets.some((ticket) => ticket.status === "inProgress");
|
||||||
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
|
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
|
||||||
|
const activeSummary = activeTicket
|
||||||
|
? conversations.get(activeTicket.conversationId)
|
||||||
|
: undefined;
|
||||||
const statusLabel = agentStatusLabel(live, busy);
|
const statusLabel = agentStatusLabel(live, busy);
|
||||||
const taskSummary = backgroundTaskSummary(backgroundTasks);
|
const taskSummary = backgroundTaskSummary(backgroundTasks);
|
||||||
const activityLine =
|
const activityLine =
|
||||||
@ -696,6 +699,13 @@ function AgentRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!detailsOpen && activeSummary && (
|
||||||
|
<ConversationSummaryLine
|
||||||
|
summary={activeSummary}
|
||||||
|
onOpenConversation={onOpenConversation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{detailsOpen && (
|
{detailsOpen && (
|
||||||
<div
|
<div
|
||||||
id={`work-agent-details-${agent.agentId}`}
|
id={`work-agent-details-${agent.agentId}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user