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:
@ -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);
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(), {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user