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

@ -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();

View File

@ -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<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(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
<LayoutGrid projectId={projectId} cwd={cwd} />
</DIProvider>,
);
}
@ -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(
<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 () => {
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();
});
});

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",

View File

@ -298,6 +298,24 @@ describe("applyOperation — conversationId / agentWasRunning persistence", () =
).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 -------------------------------------------------------
it("setAgentRunning true sets the flag", () => {
const after = applyOperation(base(), {

View File

@ -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) => {

View File

@ -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<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`.
*/
@ -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,
};