From b0d68c2d1424f21abf3d1cb793fd73c746bd94ba Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 18:22:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(application,frontend):=20restauration?= =?UTF-8?q?=20correcte=20d'une=20cellule=20agent=20en=20CLI=20custom=20apr?= =?UTF-8?q?=C3=A8s=20changement=20de=20projet=20=E2=80=94=20#322=20(QA=20v?= =?UTF-8?q?erte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/application/tests/layout_usecases.rs | 28 ++- frontend/src/domain/index.ts | 5 + .../LayoutGrid.cellControlsLayering.test.tsx | 6 +- .../features/layout/LayoutGrid.chat.test.tsx | 149 ++++++++++-- frontend/src/features/layout/LayoutGrid.tsx | 224 +++++++++--------- frontend/src/features/layout/layout.test.ts | 18 ++ frontend/src/features/layout/layout.ts | 16 ++ frontend/src/features/layout/useLayout.ts | 10 + 8 files changed, 322 insertions(+), 134 deletions(-) diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index 7e4bc7ab..2d0ad564 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -940,6 +940,16 @@ async fn mutate_set_cell_conversation_missing_leaf_is_not_found() { #[tokio::test] async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { let env = mut_env(pid(54)).await; + let original = LeafCell { + id: nid(1), + session: Some(sid(54)), + agent: Some(aid(54)), + conversation_id: Some("conversation-54".to_owned()), + engine_session_id: Some("engine-session-54".to_owned()), + agent_was_running: true, + preferred_view: PreferredView::Tui, + }; + seed_layouts(&env.fs, lid(1), &LayoutTree::single(original.clone())); env.mutate .execute(MutateLayoutInput { @@ -955,6 +965,16 @@ async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { let tree_json = active_tree_json(&env.fs); assert_eq!(tree_json["root"]["node"]["preferredView"], "chat"); + assert_eq!( + tree_json["root"]["node"]["conversationId"], + "conversation-54" + ); + assert_eq!( + tree_json["root"]["node"]["engineSessionId"], + "engine-session-54" + ); + assert_eq!(tree_json["root"]["node"]["session"], sid(54).to_string()); + assert_eq!(tree_json["root"]["node"]["agentWasRunning"], true); let out = env .mutate @@ -970,7 +990,13 @@ async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { .expect("set_cell_preferred_view restores tui"); match &out.layout.root { - LayoutNode::Leaf(l) => assert_eq!(l.preferred_view, PreferredView::Tui), + LayoutNode::Leaf(l) => { + assert_eq!(l.preferred_view, PreferredView::Tui); + assert_eq!(l.session, original.session); + assert_eq!(l.conversation_id, original.conversation_id); + assert_eq!(l.engine_session_id, original.engine_session_id); + assert_eq!(l.agent_was_running, original.agent_was_running); + } _ => panic!("expected leaf root"), } let tree_json = active_tree_json(&env.fs); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 250ba853..c56c1238 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1050,6 +1050,9 @@ export interface ProjectMcpToolPermissions { /** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */ export type Direction = "row" | "column"; +/** Preferred live surface persisted on an agent layout cell. */ +export type PreferredView = "tui" | "chat"; + /** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any. * `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal). * `conversationId` is the persistent CLI conversation id (survives PTY close/reopen, @@ -1062,6 +1065,7 @@ export interface LeafCell { agent?: string; conversationId?: string; agentWasRunning?: boolean; + preferredView?: PreferredView; } /** A weighted child of a split. `weight` is a relative (`> 0`) share. */ @@ -1133,6 +1137,7 @@ export type LayoutOperation = | { type: "setSession"; target: string; session?: string | null } | { type: "setCellAgent"; target: string; agent: string | null } | { type: "setCellConversation"; target: string; conversationId: string | null } + | { type: "setCellPreferredView"; target: string; preferredView: PreferredView } | { type: "setAgentRunning"; target: string; running: boolean } | { type: "setPluginLayoutState"; target: string; state: unknown }; diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 96212146..4dede012 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -7,7 +7,7 @@ * LayoutGrid/LeafView composition and stub only xterm, as the neighboring layout * tests do under jsdom. */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ComponentProps } from "react"; @@ -193,6 +193,10 @@ function expectedOperation(action: CellAction, cellId: string) { }); } +beforeEach(() => { + window.localStorage.clear(); +}); + describe("LayoutGrid — ticket #48 cell control layering", () => { it("shows restart only for an agent cell and disables its inputs while scheduled", async () => { const setup = await makeSplitAgentGrid(); diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index 69818b2c..6fdc3528 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -55,7 +55,7 @@ import { } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; -import { LayoutGrid, shouldFallbackCustomCliMode } from "./LayoutGrid"; +import { LayoutGrid } from "./LayoutGrid"; const structuredProfile: AgentProfile = { id: "mock-structured", @@ -119,10 +119,11 @@ async function seeded(profile: AgentProfile): Promise { } as unknown as Gateways; } -function renderGrid(gateways: Gateways) { +function renderGrid(gateways: Gateways, projectId = "p1") { + const cwd = projectId === "p1" ? "/home/me/proj" : `/home/me/${projectId}`; return render( - + , ); } @@ -182,6 +183,109 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); }); + it("restores preferredView=chat ahead of stale localStorage without a resume warning", async () => { + const gateways = await seeded(structuredProfile); + const layout = gateways.layout as MockLayoutGateway; + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellPreferredView", + target: leafId, + preferredView: "chat", + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "tui"); + + renderGrid(gateways); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" })) + .toBeNull(); + expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull(); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); + }); + + it("restores a project custom CLI cell after switching away and back without rewriting durable ids", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + const workState = new MockWorkStateGateway(); + await profileGateway.configureProfiles([structuredProfile]); + + const agentA = await agent.createAgent("p1", { + name: "Worker A", + profileId: structuredProfile.id, + }); + const treeA = await layout.loadLayout("p1"); + const leafA = leaves(treeA)[0]; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafA.id, + agent: agentA.id, + }); + await layout.mutateLayout("p1", { + type: "setCellPreferredView", + target: leafA.id, + preferredView: "chat", + }); + + const agentB = await agent.createAgent("p2", { + name: "Worker B", + profileId: structuredProfile.id, + }); + const treeB = await layout.loadLayout("p2"); + const leafB = leaves(treeB)[0]; + await layout.mutateLayout("p2", { + type: "setCellAgent", + target: leafB.id, + agent: agentB.id, + }); + + const gateways = { + layout, + agent, + profile: profileGateway, + terminal, + system, + workState, + } as unknown as Gateways; + const view = renderGrid(gateways, "p1"); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + const leaf = leaves(updated)[0]; + expect(leaf.preferredView).toBe("chat"); + expect(leaf.session).toMatch(/^mock-agent-chat-/); + expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/); + }); + const persistedA = leaves(await layout.loadLayout("p1"))[0]; + + view.rerender( + + + , + ); + await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull()); + + view.rerender( + + + , + ); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + expect(screen.queryByTestId("terminal-view")).toBeNull(); + expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" })) + .toBeNull(); + expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull(); + const restoredA = leaves(await layout.loadLayout("p1"))[0]; + expect(restoredA.preferredView).toBe("chat"); + expect(restoredA.session).toBe(persistedA.session); + expect(restoredA.conversationId).toBe(persistedA.conversationId); + }); + it("keeps custom CLI visible while busy and locks only the composer (#247)", async () => { const gateways = await seeded(structuredProfile); const layout = gateways.layout as MockLayoutGateway; @@ -553,6 +657,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => { ); expect(stopLive).not.toHaveBeenCalled(); await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy()); + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].preferredView).toBeUndefined(); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"); }); it("switches a live native TUI session to custom CLI without stopping it first (#189)", async () => { @@ -695,7 +802,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { 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(); + expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(); staleCatalog = false; system.emit({ @@ -710,7 +817,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { 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 () => { + it("keeps the custom preference and shows a non-destructive state when the profile is incompatible", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); @@ -758,29 +865,16 @@ describe("LayoutGrid custom agent CLI (#147)", () => { profilesLoaded.resolve(); await waitFor(() => - expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"), + expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(), ); - expect(screen.getByTestId("terminal-view")).toBeTruthy(); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].preferredView).toBe("chat"); + expect(screen.queryByTestId("terminal-view")).toBeNull(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); }); - it("does not downgrade a validated custom CLI on a contradictory refetch without a profile-change event (#149)", () => { - expect( - shouldFallbackCustomCliMode({ - agentsLoaded: true, - profilesLoaded: true, - cellMode: "custom", - hasPinnedAgent: true, - hasPinnedProfile: true, - customCliAvailable: false, - effectiveCustomCliAvailable: true, - hasTrustedCustomCli: true, - hasLegitProfileChange: false, - }), - ).toBe(false); - }); - - it("falls back to native TUI after a legitimate profile-change event downgrades the profile (#149)", async () => { + it("does not overwrite the custom preference after a profile-change downgrade", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); @@ -820,9 +914,12 @@ describe("LayoutGrid custom agent CLI (#147)", () => { }); await waitFor(() => - expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"), + expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(), ); - expect(screen.getByTestId("terminal-view")).toBeTruthy(); + expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].preferredView).toBe("chat"); + expect(screen.queryByTestId("terminal-view")).toBeNull(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); }); }); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 3dac7c95..4c9d52ff 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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 ( (() => { - 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()); 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(null); const [trustedCustomCli, setTrustedCustomCli] = useState(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({ })} - {effectiveCustomCliAvailable && ( + {(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
void setSessionWithInstrumentation("custom", sid)} onConversationId={(cid) => void vm.setCellConversation(id, cid)} /> + ) : agentId && cellMode === "custom" ? ( +
+ + CLI custom indisponible + + + Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native. + + +
) : ( -

- {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."} -

+ {conversationId && ( +

+ Une reprise est possible si le profil et le backend conservent cette conversation. +

+ )}
{ + const chat = applyOperation(base(), { + type: "setCellPreferredView", + target: "a", + preferredView: "chat", + }); + expect(leaves(chat)[0].preferredView).toBe("chat"); + + const tui = applyOperation(chat, { + type: "setCellPreferredView", + target: "a", + preferredView: "tui", + }); + expect(leaves(tui)[0].preferredView).toBeUndefined(); + expect("preferredView" in leaves(tui)[0]).toBe(false); + }); + // --- setAgentRunning ------------------------------------------------------- it("setAgentRunning true sets the flag", () => { const after = applyOperation(base(), { diff --git a/frontend/src/features/layout/layout.ts b/frontend/src/features/layout/layout.ts index d99dc363..81b10b41 100644 --- a/frontend/src/features/layout/layout.ts +++ b/frontend/src/features/layout/layout.ts @@ -265,6 +265,22 @@ export function applyOperation( if (!found) throw notFound(op.target); return { root }; } + case "setCellPreferredView": { + let found = false; + const root = mapNode(tree.root, (n) => { + if (n.type === "leaf" && n.node.id === op.target) { + found = true; + const { preferredView: _preferredView, ...rest } = n.node; + const updated = op.preferredView === "chat" + ? { ...rest, preferredView: "chat" as const } + : rest; + return { type: "leaf", node: updated }; + } + return n; + }); + if (!found) throw notFound(op.target); + return { root }; + } case "setAgentRunning": { let found = false; const root = mapNode(tree.root, (n) => { diff --git a/frontend/src/features/layout/useLayout.ts b/frontend/src/features/layout/useLayout.ts index dec6004f..f50354db 100644 --- a/frontend/src/features/layout/useLayout.ts +++ b/frontend/src/features/layout/useLayout.ts @@ -15,6 +15,7 @@ import type { GatewayError, LayoutOperation, LayoutTree, + PreferredView, } from "@/domain"; import { useGateways } from "@/app/di"; import { @@ -67,6 +68,8 @@ export interface LayoutViewModel { * to persist the id assigned at first launch so the next open resumes it. */ setCellConversation: (target: string, conversationId: string | null) => Promise; + /** Persists the preferred TUI or structured-chat surface for one agent cell. */ + setCellPreferredView: (target: string, preferredView: PreferredView) => Promise; /** * Persists a `customPluginLayout` node's opaque state through `mutate_layout`. */ @@ -269,6 +272,12 @@ export function useLayout( [mutate], ); + const setCellPreferredView = useCallback( + (target: string, preferredView: PreferredView) => + mutate({ type: "setCellPreferredView", target, preferredView }), + [mutate], + ); + const setPluginLayoutState = useCallback( async (nodeId: string, state: unknown) => { await mutate({ type: "setPluginLayoutState", target: nodeId, state }); @@ -294,6 +303,7 @@ export function useLayout( setCellAgent, attachLiveAgentToCell, setCellConversation, + setCellPreferredView, setPluginLayoutState, replacePluginLayoutWithTerminal, }; From a3c2005938f6191a5f225bfd2b5686912925c6c2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 18:23:10 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(frontend):=20persistance=20imm=C3=A9dia?= =?UTF-8?q?te=20de=20l'historique=20des=20prompts=20=C3=A0=20chaque=20envo?= =?UTF-8?q?i=20=E2=80=94=20#323=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptôme: l'historique du composer CLI custom (flèche haut) n'était écrit dans localStorage qu'via un effet sur l'état, sans garantie au moment de l'envoi — une fermeture brutale/crash perdait la dernière entrée. - writeComposerHistory retourne désormais le succès de l'écriture; à chaque envoi, l'entrée est ajoutée et écrite AVANT l'invocation de l'agent (source de vérité par ref + état, réinit de l'index de navigation et du brouillon d'historique) - l'effet d'écriture global est supprimé: l'historique n'est plus réécrit en boucle, seulement aux envois (et lectures au changement de scope projet/layout/node) - si l'écriture échoue, l'envoi part quand même et un avertissement discret « Historique des prompts non sauvegardé. » (role=status) s'affiche au-dessus du composer - tests: persistance vérifiée au moment de l'appel sendAgentChat, envoi conservé + avertissement affiché quand localStorage échoue QA verte sur fix/batch-322-323-session-restore-prompt-history. Aucun push. --- .../agents/CustomAgentChatView.test.tsx | 60 +++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 36 +++++++---- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index e5aa2ec7..3048df8e 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -864,6 +864,66 @@ describe("CustomAgentChatView", () => { await waitFor(() => expect(composer.value).toBe("remember me")); }); + it("persists an accepted text prompt before invoking the agent", async () => { + const historyKey = "idea.custom-chat-history.project-1.layout-1.node-1.agent-1"; + let historyAtSend: string[] = []; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), + sendAgentChat: vi.fn( + async (_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => { + historyAtSend = JSON.parse(window.localStorage.getItem(historyKey) ?? "[]"); + onChunk({ kind: "final", content: "done" }); + }, + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + + const composer = await screen.findByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "persist before send" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce()); + expect(historyAtSend).toEqual(["persist before send"]); + }); + + it("keeps sending when prompt-history persistence fails and reports it discreetly", async () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage unavailable"); + }); + try { + const agent = renderHistoryChat(); + const composer = await screen.findByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "still send" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce()); + expect(screen.getByRole("status").textContent).toContain( + "Historique des prompts non sauvegardé.", + ); + } finally { + setItem.mockRestore(); + } + }); + it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 29544975..6620805d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -472,15 +472,16 @@ function readComposerHistory(storageKey: string): string[] { } } -function writeComposerHistory(storageKey: string, history: string[]): void { - if (typeof window === "undefined") return; +function writeComposerHistory(storageKey: string, history: string[]): boolean { + if (typeof window === "undefined") return true; try { window.localStorage.setItem( storageKey, JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), ); + return true; } catch { - /* local history only */ + return false; } } @@ -1151,6 +1152,8 @@ export function CustomAgentChatView({ const [composerHistory, setComposerHistory] = useState(() => readComposerHistory(composerHistoryKey), ); + const composerHistoryRef = useRef(composerHistory); + const [composerHistoryWarning, setComposerHistoryWarning] = useState(null); const [composerHistoryIndex, setComposerHistoryIndex] = useState(null); const displayTurns = turns; @@ -1304,15 +1307,13 @@ export function CustomAgentChatView({ useEffect(() => { const restored = readComposerHistory(composerHistoryKey); + composerHistoryRef.current = restored; setComposerHistory(restored); + setComposerHistoryWarning(null); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; }, [composerHistoryKey]); - useEffect(() => { - writeComposerHistory(composerHistoryKey, composerHistory); - }, [composerHistory, composerHistoryKey]); - // Attachments can be large, so keep draft bytes only in this renderer's // bounded lifetime instead of serialising base64 into localStorage. The // project/layout/node scope mirrors the persisted text draft contract. @@ -2246,6 +2247,17 @@ export function CustomAgentChatView({ async function send() { const text = draft.trim(); if (!canSend || !agent.sendAgentChat) return; + if (text) { + const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text); + composerHistoryRef.current = nextHistory; + const persisted = writeComposerHistory(composerHistoryKey, nextHistory); + setComposerHistory(nextHistory); + setComposerHistoryWarning( + persisted ? null : "Historique des prompts non sauvegardé.", + ); + setComposerHistoryIndex(null); + draftBeforeHistoryNavigationRef.current = ""; + } const slashInvocation = slashCommandInvocation(text); if (slashInvocation) { await executeSlashCommand(slashInvocation); @@ -2263,11 +2275,6 @@ export function CustomAgentChatView({ markProjectCliInteractionFinished(projectId, interactionId); activeInteractionIdRef.current = null; }; - if (text) { - setComposerHistory((prev) => appendComposerHistoryEntry(prev, text)); - setComposerHistoryIndex(null); - draftBeforeHistoryNavigationRef.current = ""; - } setDraft(""); setAttachments([]); imageTokenSeqRef.current = 0; @@ -2705,6 +2712,11 @@ export function CustomAgentChatView({ })}
)} + {composerHistoryWarning && ( +

+ {composerHistoryWarning} +

+ )}