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:
2026-08-22 11:30:38 +02:00
parent 5449526157
commit da911d5a9b
5 changed files with 270 additions and 2 deletions

View File

@ -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({
<span>Chargement du fil…</span>
</div>
) : 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) => {
const side = sideOf(turn.source);