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