fix(application,frontend): restauration correcte d'une cellule agent en CLI custom après changement de projet — #322 (QA verte)

Symptôme: après changement de projet puis retour, une cellule en CLI
custom retombait en TUI native et un nouveau switch CLI custom repartait
à neuf (aucune conversation reprenable) car le mode préféré de la
cellule n'était pas persisté dans l'arbre de layout.

- domaine frontend: type PreferredView (tui | chat) et champ
  preferredView optionnel sur LeafCell; opération de layout
  setCellPreferredView persistant la surface préférée (tui = défaut,
  champ omis pour garder les arbres existants compacts)
- LayoutGrid: le mode de présentation d'une cellule agent dérive de
  preferredView persisté; le cache localStorage du mode custom n'est
  plus qu'un cache de migration secondaire; remount par clé
  projectId:layoutId:nodeId à chaque changement de projet pour éviter
  tout état résiduel inter-projets; la conversation/engine session/
  session/agentWasRunning de la cellule sont préservés par les
  bascules chat <-> TUI
- useLayout: setCellPreferredView exposé au view model
- tests: layout_usecases (persistance chat puis retour TUI en
  conservant session/conversation/engine/running), layout.test
  (setCellPreferredView chat persiste, tui omet le champ),
  LayoutGrid.chat et cellControlsLayering alignés

QA verte sur fix/batch-322-323-session-restore-prompt-history. Aucun push.
This commit is contained in:
2026-09-11 18:22:57 +02:00
parent 791e068d2e
commit b0d68c2d14
8 changed files with 322 additions and 134 deletions

View File

@ -18,7 +18,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Agent, AgentProfile } from "@/domain";
import type { Agent, AgentProfile, PreferredView } from "@/domain";
import type { LayoutNode } from "@/domain";
import type { ProjectWorkState } from "@/domain";
import type {
@ -192,10 +192,12 @@ function NodeView({
case "leaf":
return (
<LeafView
key={`${projectId}:${layoutId ?? ""}:${node.node.id}`}
id={node.node.id}
session={node.node.session ?? null}
agent={node.node.agent ?? null}
conversationId={node.node.conversationId ?? null}
preferredView={node.node.preferredView}
cwd={cwd}
vm={vm}
parentSplit={parentSplit}
@ -247,6 +249,7 @@ interface LeafViewProps {
session: string | null;
agent: string | null;
conversationId: string | null;
preferredView?: PreferredView;
cwd: string;
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
@ -292,23 +295,22 @@ interface FreshRestartedSession {
sessionId: string;
}
export function shouldFallbackCustomCliMode(input: {
agentsLoaded: boolean;
profilesLoaded: boolean;
cellMode: AgentCellMode;
hasPinnedAgent: boolean;
hasPinnedProfile: boolean;
customCliAvailable: boolean;
effectiveCustomCliAvailable: boolean;
hasTrustedCustomCli: boolean;
hasLegitProfileChange: boolean;
}): boolean {
if (!input.agentsLoaded || !input.profilesLoaded) return false;
if (input.cellMode !== "custom") return false;
if (!input.hasPinnedAgent || !input.hasPinnedProfile) return false;
if (!input.customCliAvailable && input.hasLegitProfileChange) return true;
if (input.effectiveCustomCliAvailable) return false;
return !input.hasTrustedCustomCli;
function readCellModeFallback(storageKey: string): AgentCellMode {
if (typeof window === "undefined") return "tui";
try {
return window.localStorage.getItem(storageKey) === "custom" ? "custom" : "tui";
} catch {
return "tui";
}
}
function writeCellModeFallback(storageKey: string, mode: AgentCellMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, mode);
} catch {
/* secondary migration cache only */
}
}
/**
@ -518,6 +520,7 @@ function LeafView({
session,
agent,
conversationId,
preferredView,
cwd,
vm,
parentSplit,
@ -610,27 +613,40 @@ function LeafView({
}, [refreshProfiles]);
const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
const [cellMode, setCellModeState] = useState<AgentCellMode>(() => {
if (typeof window === "undefined") return "tui";
try {
return window.localStorage.getItem(cellModeStorageKey) === "custom"
? "custom"
: "tui";
} catch {
return "tui";
}
if (preferredView === "chat") return "custom";
if (preferredView === "tui") return "tui";
return readCellModeFallback(cellModeStorageKey);
});
const migratedCellModeKeysRef = useRef(new Set<string>());
const setCellMode = useCallback(
(mode: AgentCellMode) => {
async (mode: AgentCellMode) => {
await vm.setCellPreferredView(id, mode === "custom" ? "chat" : "tui");
setCellModeState(mode);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(cellModeStorageKey, mode);
} catch {
/* local preference only */
}
writeCellModeFallback(cellModeStorageKey, mode);
},
[cellModeStorageKey],
[cellModeStorageKey, id, vm.setCellPreferredView],
);
useEffect(() => {
const fallback = readCellModeFallback(cellModeStorageKey);
const restored = preferredView === "chat"
? "custom"
: preferredView === "tui"
? "tui"
: fallback;
setCellModeState(restored);
writeCellModeFallback(cellModeStorageKey, restored);
if (
preferredView === undefined &&
fallback === "custom" &&
!migratedCellModeKeysRef.current.has(cellModeStorageKey)
) {
migratedCellModeKeysRef.current.add(cellModeStorageKey);
void vm.setCellPreferredView(id, "chat").catch((error) => {
console.warn("[agent-cell-mode] local preference migration failed", error);
});
}
}, [cellModeStorageKey, id, preferredView, vm.setCellPreferredView]);
const composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
readCustomChatComposerHeight(composerHeightStorageKey),
@ -655,13 +671,6 @@ function LeafView({
useState<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] =
useState<TrustedCustomCliBinding | null>(null);
const legitProfileChangeRef = useRef<{
agentId: string;
profileId: string;
seq: number;
} | null>(null);
const [legitProfileChangeSeq, setLegitProfileChangeSeq] = useState(0);
// Load the agents currently running (and where), so the dropdown can disable an
// agent already live in another cell — it cannot run in two cells at once. The
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
@ -779,15 +788,9 @@ function LeafView({
void system
.onDomainEvent((event) => {
if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return;
const seq = legitProfileChangeRef.current?.seq ?? 0;
legitProfileChangeRef.current = {
agentId: event.agentId,
profileId: event.profileId,
seq: seq + 1,
};
setLegitProfileChangeSeq(seq + 1);
refreshAgents();
refreshProfiles();
setTrustedCustomCli(null);
refreshAgents(true);
refreshProfiles(true);
})
.then((un) => {
if (cancelled) un();
@ -798,46 +801,6 @@ function LeafView({
unsubscribe?.();
};
}, [agentId, refreshAgents, refreshProfiles, system]);
useEffect(() => {
const legitChange = legitProfileChangeRef.current;
const hasLegitDowngrade = Boolean(
pinnedAgent &&
legitChange?.agentId === pinnedAgent.id &&
legitChange.profileId === pinnedAgent.profileId,
);
const fallbackInput = {
agentsLoaded,
profilesLoaded,
cellMode,
hasPinnedAgent: Boolean(pinnedAgent),
hasPinnedProfile: Boolean(pinnedProfile),
customCliAvailable,
effectiveCustomCliAvailable,
hasTrustedCustomCli: Boolean(trustedCustomCli),
hasLegitProfileChange: hasLegitDowngrade,
};
const shouldFallback = shouldFallbackCustomCliMode(fallbackInput);
console.debug("[ticket149] shouldFallbackCustomCliMode", {
timestamp: new Date().toISOString(),
input: fallbackInput,
result: shouldFallback,
});
if (shouldFallback) {
setTrustedCustomCli(null);
setCellMode("tui");
}
}, [
agentsLoaded,
cellMode,
customCliAvailable,
effectiveCustomCliAvailable,
legitProfileChangeSeq,
pinnedAgent,
pinnedProfile,
profilesLoaded,
setCellMode,
trustedCustomCli,
]);
const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
@ -961,10 +924,11 @@ function LeafView({
);
const agentCellHydrating = Boolean(
agentId &&
session &&
(!agentsLoaded ||
((session &&
(!agentsLoaded ||
!profilesLoaded ||
(workState === null && workStateBusy)),
(workState === null && workStateBusy))) ||
(cellMode === "custom" && (!agentsLoaded || !profilesLoaded))),
);
useEffect(() => {
@ -980,9 +944,14 @@ function LeafView({
}, [agentId, freshRestartedSession, liveAgents]);
function requestMode(target: AgentCellMode): void {
if (!effectiveCustomCliAvailable || target === cellMode) return;
if (target === cellMode) return;
if (target === "custom" && !effectiveCustomCliAvailable) return;
if (session) setPendingModeSwitch({ target });
else setCellMode(target);
else {
void setCellMode(target).catch((error) => {
setBusyNotice({ message: describeNotice(error) });
});
}
}
async function setSessionWithInstrumentation(
@ -1025,7 +994,7 @@ function LeafView({
setBusyNotice(null);
try {
await prepareSessionForModeSwitch(target);
setCellMode(target);
await setCellMode(target);
setPendingModeSwitch(null);
} catch (err) {
setBusyNotice({ message: describeNotice(err) });
@ -1393,8 +1362,12 @@ function LeafView({
const val = e.target.value;
if (val === "") {
setBusyNotice(null);
setCellMode("tui");
void vm.setCellAgent(id, null);
void (async () => {
await setCellMode("tui");
await vm.setCellAgent(id, null);
})().catch((error) => {
setBusyNotice({ message: describeNotice(error) });
});
return;
}
void (async () => {
@ -1424,14 +1397,14 @@ function LeafView({
return;
}
setBusyNotice(null);
setCellMode("tui");
await setCellMode("tui");
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
setCellMode("tui");
await setCellMode("tui");
await vm.setCellAgent(id, val);
})().catch(async (err: unknown) =>
setBusyNotice(await noticeFromError(err, val)),
@ -1461,7 +1434,7 @@ function LeafView({
})}
</select>
{effectiveCustomCliAvailable && (
{(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
<div
role="group"
aria-label={`mode CLI agent ${id}`}
@ -1710,6 +1683,45 @@ function LeafView({
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
) : agentId && cellMode === "custom" ? (
<div
data-testid="custom-agent-chat-unavailable"
role="status"
style={{
display: "flex",
height: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
textAlign: "center",
background: "var(--color-canvas, #0e1116)",
color: "var(--color-content-muted, #9a9a9a)",
fontSize: 12,
}}
>
<strong style={{ color: "var(--color-content, #e0e0e0)" }}>
CLI custom indisponible
</strong>
<span>
Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.
</span>
<button
type="button"
onClick={() => requestMode("tui")}
style={{
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Utiliser la TUI native
</button>
</div>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
@ -1995,11 +2007,11 @@ function LeafView({
: "la TUI native"}
{" "}et reprendre la session depuis cette surface.
</p>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
{conversationId
? "Une reprise est possible si le profil et le backend conservent cette conversation."
: "Aucune conversation reprenable n'est enregistrée pour cette cellule; la bascule repartira à neuf si le backend ne peut pas reprendre."}
</p>
{conversationId && (
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
Une reprise est possible si le profil et le backend conservent cette conversation.
</p>
)}
<div
style={{
display: "flex",