From 61055779c44fb8619265b9019efd23524206b40f Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 5 Aug 2026 14:06:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20retarde=20le=20fallback=20custom?= =?UTF-8?q?=E2=86=92TUI=20tant=20que=20le=20catalogue=20agents/profiles=20?= =?UTF-8?q?est=20stale=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause racine restante après le fix de la course au premier chargement : le garde-fou de LayoutGrid retombait sur `tui` dès que agents/profiles semblaient indisponibles, y compris quand le cache était simplement stale (refresh en cours), écrasant silencieusement la préférence custom. Le fallback n'agit désormais qu'une fois le catalogue confirmé à jour. QA verte : LayoutGrid.chat.test.tsx (7 tests), src/features/layout (16 fichiers, 130 tests), cas ciblé "stale catalog refresh". Co-Authored-By: Claude Opus 4.8 --- .../features/layout/LayoutGrid.chat.test.tsx | 59 ++++++++++++++ frontend/src/features/layout/LayoutGrid.tsx | 79 +++++++++++++++---- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index 0948baa..eed4c48 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -254,6 +254,65 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); }); + it("keeps restored custom CLI mode until a stale catalog refresh sees the agent/profile (#149)", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + await profileGateway.configureProfiles([structuredProfile]); + const created = await agent.createAgent("p1", { + name: "Worker", + profileId: structuredProfile.id, + }); + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: created.id, + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + + let staleCatalog = true; + const originalListAgents = agent.listAgents.bind(agent); + const originalListProfiles = profileGateway.listProfiles.bind(profileGateway); + vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => { + if (staleCatalog) return []; + return originalListAgents(projectId); + }); + vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => { + if (staleCatalog) return []; + return originalListProfiles(); + }); + + renderGrid({ + layout, + agent, + profile: profileGateway, + terminal, + system, + } as unknown as Gateways); + + await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); + await waitFor(() => expect(agent.listAgents).toHaveBeenCalled()); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); + expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); + expect(screen.getByTestId("terminal-view")).toBeTruthy(); + + staleCatalog = false; + system.emit({ + type: "agentProfileChanged", + agentId: created.id, + profileId: structuredProfile.id, + }); + + await waitFor(() => + expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), + ); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); + }); + it("falls back to native TUI once the catalog confirms the pinned profile is incompatible", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 098de80..6a62bc3 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -360,55 +360,64 @@ function LeafView({ // simply not passed to the terminal. const { portal, overlay } = useWritePortal(projectId, agent ?? null); - // Load the project's agents for the dropdown. + // Load the project's agents for the dropdown and for the pinned + // agent->profile correlation. The catalogue can change after this cell has + // mounted (agent creation/profile hot-swap), so the loader is reusable by the + // event refreshes below. const [agents, setAgents] = useState([]); const [agentsLoaded, setAgentsLoaded] = useState(false); - useEffect(() => { - setAgentsLoaded(false); + const agentsRequestRef = useRef(0); + const refreshAgents = useCallback((markLoading = false): void => { + const request = ++agentsRequestRef.current; + if (markLoading) setAgentsLoaded(false); if (!agentGateway) { + setAgents([]); setAgentsLoaded(true); return; } - let cancelled = false; agentGateway.listAgents(projectId).then((list) => { - if (!cancelled) { + if (agentsRequestRef.current === request) { setAgents(list); setAgentsLoaded(true); } }).catch(() => { - if (!cancelled) setAgentsLoaded(true); + if (agentsRequestRef.current === request) setAgentsLoaded(true); /* ignore — dropdown stays empty */ }); - return () => { cancelled = true; }; }, [agentGateway, projectId]); + useEffect(() => { + refreshAgents(true); + }, [refreshAgents]); const [profiles, setProfiles] = useState([]); const [profilesLoaded, setProfilesLoaded] = useState(false); - useEffect(() => { - setProfilesLoaded(false); + const profilesRequestRef = useRef(0); + const refreshProfiles = useCallback((markLoading = false): void => { + const request = ++profilesRequestRef.current; + if (markLoading) setProfilesLoaded(false); if (!profileGateway) { + setProfiles([]); setProfilesLoaded(true); return; } - let cancelled = false; profileGateway .listProfiles() .then((list) => { - if (!cancelled) { + if (profilesRequestRef.current === request) { setProfiles(list); setProfilesLoaded(true); } }) .catch(() => { - if (!cancelled) { + if (profilesRequestRef.current === request) { setProfiles([]); setProfilesLoaded(true); } }); - return () => { - cancelled = true; - }; }, [profileGateway]); + useEffect(() => { + refreshProfiles(true); + }, [refreshProfiles]); const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`; const [cellMode, setCellModeState] = useState(() => { if (typeof window === "undefined") return "tui"; @@ -508,10 +517,46 @@ function LeafView({ agentGateway?.cancelAgentChat && agentGateway?.closeAgentChat, ); + useEffect(() => { + if (!agentId) return; + refreshAgents(); + }, [agentId, refreshAgents]); + useEffect(() => { + if (!pinnedAgent?.profileId) return; + refreshProfiles(); + }, [pinnedAgent?.profileId, refreshProfiles]); + useEffect(() => { + if (!system || !agentId) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system + .onDomainEvent((event) => { + if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return; + refreshAgents(); + refreshProfiles(); + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [agentId, refreshAgents, refreshProfiles, system]); useEffect(() => { if (!agentsLoaded || !profilesLoaded) return; - if (!customCliAvailable && cellMode !== "tui") setCellMode("tui"); - }, [agentsLoaded, cellMode, customCliAvailable, profilesLoaded, setCellMode]); + if (cellMode !== "custom") return; + if (pinnedAgent && pinnedProfile && !customCliAvailable) setCellMode("tui"); + }, [ + agentsLoaded, + cellMode, + customCliAvailable, + pinnedAgent, + pinnedProfile, + profilesLoaded, + setCellMode, + ]); const modelServerStatus = statusForAgent(pinnedAgent); const modelServerOverlay = modelServerOverlayText(modelServerStatus); // F2 — download progress (bar/%/bytes/source) when the status carries it; null