fix(frontend): restaure l'historique durable au retour sur une conversation existante sans session live — freshOnOpen réservé au reset explicite (retiré de LayoutGrid et WebAgentCell), conversationId persistant de la cellule réutilisé au remontage ordinaire avec chargement de l'historique via conversation.readPage (machine savedConversationLoadState idle/loading/loaded/error/notFound, pagination ancrée conservée, écran vide faussement « Nouvelle conversation » éliminé), bannière d'annonces live et fallback de délégation masqués sans session réellement rattachée (liveSessionAttached), /clean et le reset ouvrent explicitement une nouvelle conversation (conversationId: null), erreurs de chargement traitées avec réessai et bouton « Nouvelle conversation » à la place du déliage, composer et pièces jointes désactivés en échec de chargement — #366 (QA verte: npm test ciblé CustomAgentChatView + LayoutGrid.chat + WebAgentCell 3 fichiers 119 tests OK, npm run build OK, git diff --check OK; warning jsdom/canvas connu non bloquant, npm audit: vulnérabilités préexistantes non liées)

This commit is contained in:
2026-09-17 08:51:26 +02:00
parent 5afb2b1c65
commit e118a34c64
6 changed files with 338 additions and 229 deletions

View File

@ -1069,9 +1069,8 @@ export function CustomAgentChatView({
() => new Set(),
);
const [currentSession, setCurrentSession] = useState(sessionId);
// A newly opened custom CLI intentionally starts blank. A conversation id
// persisted on the layout is only reused when it still has a live session;
// durable threads are reopened explicitly from the Conversations menu.
// `freshOnOpen` is reserved for an explicit reset intent. Ordinary React
// remounts keep the durable conversation selected by the hosting cell.
const [activeConversationId, setActiveConversationId] = useState<string | null>(
sessionId || !freshOnOpen ? conversationId : null,
);
@ -1113,6 +1112,10 @@ export function CustomAgentChatView({
const [opening, setOpening] = useState(
Boolean(sessionId || (!freshOnOpen && conversationId)),
);
const [liveSessionAttached, setLiveSessionAttached] = useState(false);
const [savedConversationLoadState, setSavedConversationLoadState] = useState<
"idle" | "loading" | "loaded" | "error" | "notFound"
>(!sessionId && !freshOnOpen && conversationId ? "loading" : "idle");
const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
@ -1904,6 +1907,7 @@ export function CustomAgentChatView({
});
if (!mountedRef.current) return reattached.sessionId;
setCurrentSession(reattached.sessionId);
setLiveSessionAttached(true);
if (reattached.sessionId !== sid) {
publishSessionId(reattached.sessionId);
}
@ -2124,10 +2128,18 @@ export function CustomAgentChatView({
return;
}
setExternalSessionId(sessionId);
setLiveSessionAttached(false);
}, [sessionId]);
useEffect(() => {
if (!supported) return;
if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) {
setOpening(false);
setError(null);
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
return;
}
let cancelled = false;
async function openOrAttach() {
@ -2152,6 +2164,8 @@ export function CustomAgentChatView({
return;
}
if (externalSessionId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
const cached = chatSessionViewCache.get(externalSessionId);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
@ -2194,6 +2208,8 @@ export function CustomAgentChatView({
}
if (!cancelled) {
setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null);
}
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
@ -2202,6 +2218,7 @@ export function CustomAgentChatView({
sessionId: externalSessionId,
error: instrumentationError(e),
});
return;
}
} else {
const cacheKey = conversationCacheKey(
@ -2217,32 +2234,45 @@ export function CustomAgentChatView({
progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
});
if (cached.sessionId) {
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
applyScrollback: true,
}
const savedConversationId = activeConversationIdRef.current;
if (!savedConversationId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
if (freshOnOpen) {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
if (cancelled) return;
publishSessionId(reattachedSessionId);
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: reattachedSessionId,
});
return;
}
return;
}
setSavedConversationLoadState("loading");
setLiveSessionAttached(false);
try {
const page = await conversation.readPage(projectId, savedConversationId, {
direction: "backward",
limit: 50,
});
if (cancelled) return;
applyChatViewState({
...emptyChatViewState(),
turns: durableHistoryTurns(page.turns),
});
setResumedConversation(true);
setConversationNotice(null);
setHistoryHasMore(page.hasMore);
setHistoryNextAnchor(page.nextAnchor);
setHistoryError(null);
setSavedConversationLoadState("loaded");
} catch (e) {
if (cancelled) return;
setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error");
}
return;
}
await recoverStructuredSession({
applyScrollback: Boolean(activeConversationIdRef.current),
retryAttachNotFound: true,
});
if (cancelled) return;
console.debug("[ticket149] openOrAttach:recover:success", {
timestamp: new Date().toISOString(),
execution,
receivedSessionId: externalSessionId,
});
} catch (e) {
console.error("[ticket149] openOrAttach:error", {
timestamp: new Date().toISOString(),
@ -2273,6 +2303,10 @@ export function CustomAgentChatView({
recoverStructuredSession,
publishSessionId,
applyChatViewState,
conversation,
projectId,
agentId,
freshOnOpen,
openRetryNonce,
]);
@ -2280,8 +2314,10 @@ export function CustomAgentChatView({
() =>
supported &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!opening,
[supported, draft, attachments.length, opening],
!opening &&
savedConversationLoadState !== "error" &&
savedConversationLoadState !== "notFound",
[supported, draft, attachments.length, opening, savedConversationLoadState],
);
const unavailableLinkedTranscript =
displayTurns.length === 0 &&
@ -2289,16 +2325,17 @@ export function CustomAgentChatView({
transcriptUnavailableReason === "conversationNotFound")
? transcriptUnavailableReason
: null;
function unlinkUnavailableConversation() {
onConversationIdRef.current(null);
setTranscriptUnavailableReason("emptyConversation");
setError(null);
}
const savedConversationLoadFailure =
savedConversationLoadState === "error" || savedConversationLoadState === "notFound"
? savedConversationLoadState
: null;
function retryUnavailableTranscript() {
const sid = sessionRef.current;
setTranscriptUnavailableReason(null);
setSavedConversationLoadState(
!sid && activeConversationIdRef.current ? "loading" : "idle",
);
setError(null);
if (sid) {
// Force the next user-triggered pass through the attach path. Keeping the
@ -2343,9 +2380,11 @@ export function CustomAgentChatView({
if (sid) await agent.closeAgentChat?.(sid);
setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null);
publishConversationId(nextConversationId);
applyChatViewState(emptyChatViewState());
setSavedConversationLoadState("loading");
setResumedConversation(false);
setConversationNotice(null);
setHistoryHasMore(false);
@ -2492,6 +2531,7 @@ export function CustomAgentChatView({
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
...(invocation.name === "/clean" ? { conversationId: null } : {}),
}));
const result = await agent.executeSlashCommand(invocation.name, {
sessionId: sid,
@ -2513,6 +2553,7 @@ export function CustomAgentChatView({
setExternalSessionId(result.effect.sessionId);
publishSessionId(result.effect.sessionId);
publishConversationId(result.effect.conversationId ?? null);
setSavedConversationLoadState("idle");
setResumedConversation(false);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
@ -3247,13 +3288,15 @@ export function CustomAgentChatView({
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
onScroll={threadScroll.onScroll}
>
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive={delegationActive}
fallbackText={delegationFallbackText}
/>
</div>
{liveSessionAttached && delegationActive && (
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive
fallbackText={delegationFallbackText}
/>
</div>
)}
{conversationNotice && (
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
{conversationNotice}
@ -3291,18 +3334,26 @@ export function CustomAgentChatView({
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{externalSessionId ? t("custom.resume") : t("custom.opening")}
{savedConversationLoadState === "loading"
? tr("Chargement de la conversation…", "Loading conversation…")
: externalSessionId
? t("custom.resume")
: t("custom.opening")}
</span>
</div>
) : unavailableLinkedTranscript ? (
) : savedConversationLoadFailure || unavailableLinkedTranscript ? (
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
<p className="text-sm font-medium text-content">
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")}
</p>
<p className="text-xs text-muted">
{unavailableLinkedTranscript === "conversationNotFound"
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
{savedConversationLoadFailure === "notFound" ||
unavailableLinkedTranscript === "conversationNotFound"
? tr(
"Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.",
"This saved conversation could not be found. You can retry or open a new conversation.",
)
: tr(
"Impossible de charger cette conversation sauvegardée. Vos messages n'ont pas été supprimés. Réessayez ou ouvrez explicitement une nouvelle conversation.",
"Unable to load this saved conversation. Your messages have not been deleted. Retry or explicitly open a new conversation.",
)}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
@ -3313,11 +3364,18 @@ export function CustomAgentChatView({
>
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
{tr("Délier la conversation", "Unlink conversation")}
<Button size="sm" variant="secondary" onClick={() => void startNewConversation()}>
{tr("Nouvelle conversation", "New conversation")}
</Button>
</div>
</div>
) : savedConversationLoadState === "loaded" && displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
{tr(
"Cette conversation sauvegardée ne contient aucun message affichable.",
"This saved conversation contains no displayable messages.",
)}
</div>
) : displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
<strong className="block text-sm text-content">
@ -3565,7 +3623,7 @@ export function CustomAgentChatView({
)}
rows={2}
value={draft}
disabled={!supported || opening}
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
placeholder={t("custom.message")}
style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)}
@ -3623,7 +3681,7 @@ export function CustomAgentChatView({
size="sm"
variant="ghost"
className="shrink-0 whitespace-nowrap"
disabled={!supported || opening}
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
onClick={() => void pickAttachment()}
>
{t("custom.attach")}