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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user