merge(application,frontend): fix #322 #323 — restauration cellule CLI custom après changement de projet et persistance immédiate de l'historique des prompts (QA verte)

This commit is contained in:
2026-09-11 18:23:18 +02:00
10 changed files with 406 additions and 146 deletions

View File

@ -940,6 +940,16 @@ async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
#[tokio::test] #[tokio::test]
async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() {
let env = mut_env(pid(54)).await; 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 env.mutate
.execute(MutateLayoutInput { .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); let tree_json = active_tree_json(&env.fs);
assert_eq!(tree_json["root"]["node"]["preferredView"], "chat"); 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 let out = env
.mutate .mutate
@ -970,7 +990,13 @@ async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() {
.expect("set_cell_preferred_view restores tui"); .expect("set_cell_preferred_view restores tui");
match &out.layout.root { 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"), _ => panic!("expected leaf root"),
} }
let tree_json = active_tree_json(&env.fs); let tree_json = active_tree_json(&env.fs);

View File

@ -1050,6 +1050,9 @@ export interface ProjectMcpToolPermissions {
/** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */ /** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */
export type Direction = "row" | "column"; 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. /** 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). * `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, * `conversationId` is the persistent CLI conversation id (survives PTY close/reopen,
@ -1062,6 +1065,7 @@ export interface LeafCell {
agent?: string; agent?: string;
conversationId?: string; conversationId?: string;
agentWasRunning?: boolean; agentWasRunning?: boolean;
preferredView?: PreferredView;
} }
/** A weighted child of a split. `weight` is a relative (`> 0`) share. */ /** 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: "setSession"; target: string; session?: string | null }
| { type: "setCellAgent"; target: string; agent: string | null } | { type: "setCellAgent"; target: string; agent: string | null }
| { type: "setCellConversation"; target: string; conversationId: string | null } | { type: "setCellConversation"; target: string; conversationId: string | null }
| { type: "setCellPreferredView"; target: string; preferredView: PreferredView }
| { type: "setAgentRunning"; target: string; running: boolean } | { type: "setAgentRunning"; target: string; running: boolean }
| { type: "setPluginLayoutState"; target: string; state: unknown }; | { type: "setPluginLayoutState"; target: string; state: unknown };

View File

@ -864,6 +864,66 @@ describe("CustomAgentChatView", () => {
await waitFor(() => expect(composer.value).toBe("remember me")); 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(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 () => { it("repaints attachment labels from user prompt scrollback", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),

View File

@ -472,15 +472,16 @@ function readComposerHistory(storageKey: string): string[] {
} }
} }
function writeComposerHistory(storageKey: string, history: string[]): void { function writeComposerHistory(storageKey: string, history: string[]): boolean {
if (typeof window === "undefined") return; if (typeof window === "undefined") return true;
try { try {
window.localStorage.setItem( window.localStorage.setItem(
storageKey, storageKey,
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
); );
return true;
} catch { } catch {
/* local history only */ return false;
} }
} }
@ -1151,6 +1152,8 @@ export function CustomAgentChatView({
const [composerHistory, setComposerHistory] = useState<string[]>(() => const [composerHistory, setComposerHistory] = useState<string[]>(() =>
readComposerHistory(composerHistoryKey), readComposerHistory(composerHistoryKey),
); );
const composerHistoryRef = useRef(composerHistory);
const [composerHistoryWarning, setComposerHistoryWarning] = useState<string | null>(null);
const [composerHistoryIndex, setComposerHistoryIndex] = const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(null); useState<number | null>(null);
const displayTurns = turns; const displayTurns = turns;
@ -1304,15 +1307,13 @@ export function CustomAgentChatView({
useEffect(() => { useEffect(() => {
const restored = readComposerHistory(composerHistoryKey); const restored = readComposerHistory(composerHistoryKey);
composerHistoryRef.current = restored;
setComposerHistory(restored); setComposerHistory(restored);
setComposerHistoryWarning(null);
setComposerHistoryIndex(null); setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = ""; draftBeforeHistoryNavigationRef.current = "";
}, [composerHistoryKey]); }, [composerHistoryKey]);
useEffect(() => {
writeComposerHistory(composerHistoryKey, composerHistory);
}, [composerHistory, composerHistoryKey]);
// Attachments can be large, so keep draft bytes only in this renderer's // Attachments can be large, so keep draft bytes only in this renderer's
// bounded lifetime instead of serialising base64 into localStorage. The // bounded lifetime instead of serialising base64 into localStorage. The
// project/layout/node scope mirrors the persisted text draft contract. // project/layout/node scope mirrors the persisted text draft contract.
@ -2246,6 +2247,17 @@ export function CustomAgentChatView({
async function send() { async function send() {
const text = draft.trim(); const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return; 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); const slashInvocation = slashCommandInvocation(text);
if (slashInvocation) { if (slashInvocation) {
await executeSlashCommand(slashInvocation); await executeSlashCommand(slashInvocation);
@ -2263,11 +2275,6 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, interactionId); markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null; activeInteractionIdRef.current = null;
}; };
if (text) {
setComposerHistory((prev) => appendComposerHistoryEntry(prev, text));
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}
setDraft(""); setDraft("");
setAttachments([]); setAttachments([]);
imageTokenSeqRef.current = 0; imageTokenSeqRef.current = 0;
@ -2705,6 +2712,11 @@ export function CustomAgentChatView({
})} })}
</div> </div>
)} )}
{composerHistoryWarning && (
<p role="status" className="mb-1 text-[11px] text-warning">
{composerHistoryWarning}
</p>
)}
<div className="flex min-w-0 items-end gap-2"> <div className="flex min-w-0 items-end gap-2">
<div className="relative flex min-w-0 flex-1"> <div className="relative flex min-w-0 flex-1">
<textarea <textarea

View File

@ -7,7 +7,7 @@
* LayoutGrid/LeafView composition and stub only xterm, as the neighboring layout * LayoutGrid/LeafView composition and stub only xterm, as the neighboring layout
* tests do under jsdom. * 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 { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "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", () => { describe("LayoutGrid — ticket #48 cell control layering", () => {
it("shows restart only for an agent cell and disables its inputs while scheduled", async () => { it("shows restart only for an agent cell and disables its inputs while scheduled", async () => {
const setup = await makeSplitAgentGrid(); const setup = await makeSplitAgentGrid();

View File

@ -55,7 +55,7 @@ import {
} from "@/adapters/mock"; } from "@/adapters/mock";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { leaves } from "./layout"; import { leaves } from "./layout";
import { LayoutGrid, shouldFallbackCustomCliMode } from "./LayoutGrid"; import { LayoutGrid } from "./LayoutGrid";
const structuredProfile: AgentProfile = { const structuredProfile: AgentProfile = {
id: "mock-structured", id: "mock-structured",
@ -119,10 +119,11 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
} as unknown as Gateways; } 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( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" /> <LayoutGrid projectId={projectId} cwd={cwd} />
</DIProvider>, </DIProvider>,
); );
} }
@ -182,6 +183,109 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); 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(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p2" cwd="/home/me/p2" />
</DIProvider>,
);
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
view.rerender(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
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 () => { it("keeps custom CLI visible while busy and locks only the composer (#247)", async () => {
const gateways = await seeded(structuredProfile); const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway; const layout = gateways.layout as MockLayoutGateway;
@ -553,6 +657,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
); );
expect(stopLive).not.toHaveBeenCalled(); expect(stopLive).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy()); 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 () => { 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()); await waitFor(() => expect(agent.listAgents).toHaveBeenCalled());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy();
staleCatalog = false; staleCatalog = false;
system.emit({ system.emit({
@ -710,7 +817,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); 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 layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
@ -758,29 +865,16 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
profilesLoaded.resolve(); profilesLoaded.resolve();
await waitFor(() => 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(); 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)", () => { it("does not overwrite the custom preference after a profile-change downgrade", async () => {
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 () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
@ -820,9 +914,12 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
}); });
await waitFor(() => 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(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
}); });
}); });

View File

@ -18,7 +18,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 { LayoutNode } from "@/domain";
import type { ProjectWorkState } from "@/domain"; import type { ProjectWorkState } from "@/domain";
import type { import type {
@ -192,10 +192,12 @@ function NodeView({
case "leaf": case "leaf":
return ( return (
<LeafView <LeafView
key={`${projectId}:${layoutId ?? ""}:${node.node.id}`}
id={node.node.id} id={node.node.id}
session={node.node.session ?? null} session={node.node.session ?? null}
agent={node.node.agent ?? null} agent={node.node.agent ?? null}
conversationId={node.node.conversationId ?? null} conversationId={node.node.conversationId ?? null}
preferredView={node.node.preferredView}
cwd={cwd} cwd={cwd}
vm={vm} vm={vm}
parentSplit={parentSplit} parentSplit={parentSplit}
@ -247,6 +249,7 @@ interface LeafViewProps {
session: string | null; session: string | null;
agent: string | null; agent: string | null;
conversationId: string | null; conversationId: string | null;
preferredView?: PreferredView;
cwd: string; cwd: string;
vm: LayoutViewModel; vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null; parentSplit: { container: string; index: number; siblings: number } | null;
@ -292,23 +295,22 @@ interface FreshRestartedSession {
sessionId: string; sessionId: string;
} }
export function shouldFallbackCustomCliMode(input: { function readCellModeFallback(storageKey: string): AgentCellMode {
agentsLoaded: boolean; if (typeof window === "undefined") return "tui";
profilesLoaded: boolean; try {
cellMode: AgentCellMode; return window.localStorage.getItem(storageKey) === "custom" ? "custom" : "tui";
hasPinnedAgent: boolean; } catch {
hasPinnedProfile: boolean; return "tui";
customCliAvailable: boolean; }
effectiveCustomCliAvailable: boolean; }
hasTrustedCustomCli: boolean;
hasLegitProfileChange: boolean; function writeCellModeFallback(storageKey: string, mode: AgentCellMode): void {
}): boolean { if (typeof window === "undefined") return;
if (!input.agentsLoaded || !input.profilesLoaded) return false; try {
if (input.cellMode !== "custom") return false; window.localStorage.setItem(storageKey, mode);
if (!input.hasPinnedAgent || !input.hasPinnedProfile) return false; } catch {
if (!input.customCliAvailable && input.hasLegitProfileChange) return true; /* secondary migration cache only */
if (input.effectiveCustomCliAvailable) return false; }
return !input.hasTrustedCustomCli;
} }
/** /**
@ -518,6 +520,7 @@ function LeafView({
session, session,
agent, agent,
conversationId, conversationId,
preferredView,
cwd, cwd,
vm, vm,
parentSplit, parentSplit,
@ -610,27 +613,40 @@ function LeafView({
}, [refreshProfiles]); }, [refreshProfiles]);
const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`; const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
const [cellMode, setCellModeState] = useState<AgentCellMode>(() => { const [cellMode, setCellModeState] = useState<AgentCellMode>(() => {
if (typeof window === "undefined") return "tui"; if (preferredView === "chat") return "custom";
try { if (preferredView === "tui") return "tui";
return window.localStorage.getItem(cellModeStorageKey) === "custom" return readCellModeFallback(cellModeStorageKey);
? "custom"
: "tui";
} catch {
return "tui";
}
}); });
const migratedCellModeKeysRef = useRef(new Set<string>());
const setCellMode = useCallback( const setCellMode = useCallback(
(mode: AgentCellMode) => { async (mode: AgentCellMode) => {
await vm.setCellPreferredView(id, mode === "custom" ? "chat" : "tui");
setCellModeState(mode); setCellModeState(mode);
if (typeof window === "undefined") return; writeCellModeFallback(cellModeStorageKey, mode);
try {
window.localStorage.setItem(cellModeStorageKey, mode);
} catch {
/* local preference only */
}
}, },
[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 composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() => const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
readCustomChatComposerHeight(composerHeightStorageKey), readCustomChatComposerHeight(composerHeightStorageKey),
@ -655,13 +671,6 @@ function LeafView({
useState<PendingModeSwitch | null>(null); useState<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] = const [trustedCustomCli, setTrustedCustomCli] =
useState<TrustedCustomCliBinding | null>(null); 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 // 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 // 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 // backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
@ -779,15 +788,9 @@ function LeafView({
void system void system
.onDomainEvent((event) => { .onDomainEvent((event) => {
if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return; if (event.type !== "agentProfileChanged" || event.agentId !== agentId) return;
const seq = legitProfileChangeRef.current?.seq ?? 0; setTrustedCustomCli(null);
legitProfileChangeRef.current = { refreshAgents(true);
agentId: event.agentId, refreshProfiles(true);
profileId: event.profileId,
seq: seq + 1,
};
setLegitProfileChangeSeq(seq + 1);
refreshAgents();
refreshProfiles();
}) })
.then((un) => { .then((un) => {
if (cancelled) un(); if (cancelled) un();
@ -798,46 +801,6 @@ function LeafView({
unsubscribe?.(); unsubscribe?.();
}; };
}, [agentId, refreshAgents, refreshProfiles, system]); }, [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 modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus); const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null // F2 — download progress (bar/%/bytes/source) when the status carries it; null
@ -961,10 +924,11 @@ function LeafView({
); );
const agentCellHydrating = Boolean( const agentCellHydrating = Boolean(
agentId && agentId &&
session && ((session &&
(!agentsLoaded || (!agentsLoaded ||
!profilesLoaded || !profilesLoaded ||
(workState === null && workStateBusy)), (workState === null && workStateBusy))) ||
(cellMode === "custom" && (!agentsLoaded || !profilesLoaded))),
); );
useEffect(() => { useEffect(() => {
@ -980,9 +944,14 @@ function LeafView({
}, [agentId, freshRestartedSession, liveAgents]); }, [agentId, freshRestartedSession, liveAgents]);
function requestMode(target: AgentCellMode): void { function requestMode(target: AgentCellMode): void {
if (!effectiveCustomCliAvailable || target === cellMode) return; if (target === cellMode) return;
if (target === "custom" && !effectiveCustomCliAvailable) return;
if (session) setPendingModeSwitch({ target }); if (session) setPendingModeSwitch({ target });
else setCellMode(target); else {
void setCellMode(target).catch((error) => {
setBusyNotice({ message: describeNotice(error) });
});
}
} }
async function setSessionWithInstrumentation( async function setSessionWithInstrumentation(
@ -1025,7 +994,7 @@ function LeafView({
setBusyNotice(null); setBusyNotice(null);
try { try {
await prepareSessionForModeSwitch(target); await prepareSessionForModeSwitch(target);
setCellMode(target); await setCellMode(target);
setPendingModeSwitch(null); setPendingModeSwitch(null);
} catch (err) { } catch (err) {
setBusyNotice({ message: describeNotice(err) }); setBusyNotice({ message: describeNotice(err) });
@ -1393,8 +1362,12 @@ function LeafView({
const val = e.target.value; const val = e.target.value;
if (val === "") { if (val === "") {
setBusyNotice(null); setBusyNotice(null);
setCellMode("tui"); void (async () => {
void vm.setCellAgent(id, null); await setCellMode("tui");
await vm.setCellAgent(id, null);
})().catch((error) => {
setBusyNotice({ message: describeNotice(error) });
});
return; return;
} }
void (async () => { void (async () => {
@ -1424,14 +1397,14 @@ function LeafView({
return; return;
} }
setBusyNotice(null); setBusyNotice(null);
setCellMode("tui"); await setCellMode("tui");
const attached = await agentGateway.attachLiveAgent(projectId, val, id); const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId); await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive(); refreshLive();
return; return;
} }
setBusyNotice(null); setBusyNotice(null);
setCellMode("tui"); await setCellMode("tui");
await vm.setCellAgent(id, val); await vm.setCellAgent(id, val);
})().catch(async (err: unknown) => })().catch(async (err: unknown) =>
setBusyNotice(await noticeFromError(err, val)), setBusyNotice(await noticeFromError(err, val)),
@ -1461,7 +1434,7 @@ function LeafView({
})} })}
</select> </select>
{effectiveCustomCliAvailable && ( {(effectiveCustomCliAvailable || (agentId && cellMode === "custom")) && (
<div <div
role="group" role="group"
aria-label={`mode CLI agent ${id}`} aria-label={`mode CLI agent ${id}`}
@ -1710,6 +1683,45 @@ function LeafView({
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)} onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)} 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 <TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`} key={`${id}-${agentId ?? "plain"}-${attachGen}`}
@ -1995,11 +2007,11 @@ function LeafView({
: "la TUI native"} : "la TUI native"}
{" "}et reprendre la session depuis cette surface. {" "}et reprendre la session depuis cette surface.
</p> </p>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}> {conversationId && (
{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." 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>
</p> )}
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@ -298,6 +298,24 @@ describe("applyOperation — conversationId / agentWasRunning persistence", () =
).toThrowError(/not found/); ).toThrowError(/not found/);
}); });
// --- setCellPreferredView -------------------------------------------------
it("setCellPreferredView persists chat and omits the default TUI value", () => {
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 ------------------------------------------------------- // --- setAgentRunning -------------------------------------------------------
it("setAgentRunning true sets the flag", () => { it("setAgentRunning true sets the flag", () => {
const after = applyOperation(base(), { const after = applyOperation(base(), {

View File

@ -265,6 +265,22 @@ export function applyOperation(
if (!found) throw notFound(op.target); if (!found) throw notFound(op.target);
return { root }; 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": { case "setAgentRunning": {
let found = false; let found = false;
const root = mapNode(tree.root, (n) => { const root = mapNode(tree.root, (n) => {

View File

@ -15,6 +15,7 @@ import type {
GatewayError, GatewayError,
LayoutOperation, LayoutOperation,
LayoutTree, LayoutTree,
PreferredView,
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { import {
@ -67,6 +68,8 @@ export interface LayoutViewModel {
* to persist the id assigned at first launch so the next open resumes it. * to persist the id assigned at first launch so the next open resumes it.
*/ */
setCellConversation: (target: string, conversationId: string | null) => Promise<void>; setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
/** Persists the preferred TUI or structured-chat surface for one agent cell. */
setCellPreferredView: (target: string, preferredView: PreferredView) => Promise<void>;
/** /**
* Persists a `customPluginLayout` node's opaque state through `mutate_layout`. * Persists a `customPluginLayout` node's opaque state through `mutate_layout`.
*/ */
@ -269,6 +272,12 @@ export function useLayout(
[mutate], [mutate],
); );
const setCellPreferredView = useCallback(
(target: string, preferredView: PreferredView) =>
mutate({ type: "setCellPreferredView", target, preferredView }),
[mutate],
);
const setPluginLayoutState = useCallback( const setPluginLayoutState = useCallback(
async (nodeId: string, state: unknown) => { async (nodeId: string, state: unknown) => {
await mutate({ type: "setPluginLayoutState", target: nodeId, state }); await mutate({ type: "setPluginLayoutState", target: nodeId, state });
@ -294,6 +303,7 @@ export function useLayout(
setCellAgent, setCellAgent,
attachLiveAgentToCell, attachLiveAgentToCell,
setCellConversation, setCellConversation,
setCellPreferredView,
setPluginLayoutState, setPluginLayoutState,
replacePluginLayoutWithTerminal, replacePluginLayoutWithTerminal,
}; };