fix(layout): rendre resélectionnable un agent live non réellement affiché (#56)

Le sélecteur d'agent de LayoutGrid désactivait un agent en se fondant sur le
seul liveAgents.nodeId : un agent live dont l'ancienne cellule affiche désormais
un autre agent restait grisé (« visible ailleurs ») alors qu'il n'était plus
affiché nulle part, donc impossible à resélectionner.

La dérivation « visible ailleurs » s'appuie maintenant sur le layout courant
(feuille visible ≠ cellule courante ET leaf.agent === candidate). Un agent live
dont l'ancienne cellule montre un autre agent redevient sélectionnable dans une
autre cellule ; la session n'est jamais tuée (attachLiveAgent conservé). Le prop
mort visibleNodeIds est retiré du threading.

Tests : frontend/src/features/layout/singletonAgent.test.tsx. Suite frontend
verte 706/706.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:56:34 +02:00
parent fd5c8932ca
commit 4099b0d1c4
2 changed files with 154 additions and 36 deletions

View File

@ -98,8 +98,6 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
</div> </div>
); );
} }
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
return ( return (
<div <div
data-testid="layout-grid" data-testid="layout-grid"
@ -116,7 +114,6 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
vm={vm} vm={vm}
parentSplit={null} parentSplit={null}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={work.state} workState={work.state}
refreshWorkState={work.refresh} refreshWorkState={work.refresh}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@ -132,7 +129,6 @@ interface NodeViewProps {
/** The enclosing split + this node's index in it, for the merge action. */ /** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null; parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string; projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
@ -144,7 +140,6 @@ function NodeView({
vm, vm,
parentSplit, parentSplit,
projectId, projectId,
visibleNodeIds,
workState, workState,
refreshWorkState, refreshWorkState,
onOpenConversation, onOpenConversation,
@ -162,7 +157,6 @@ function NodeView({
vm={vm} vm={vm}
parentSplit={parentSplit} parentSplit={parentSplit}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@ -175,7 +169,6 @@ function NodeView({
cwd={cwd} cwd={cwd}
vm={vm} vm={vm}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@ -188,7 +181,6 @@ function NodeView({
cwd={cwd} cwd={cwd}
vm={vm} vm={vm}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@ -207,7 +199,6 @@ interface LeafViewProps {
vm: LayoutViewModel; vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null; parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string; projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
@ -289,7 +280,6 @@ function LeafView({
vm, vm,
parentSplit, parentSplit,
projectId, projectId,
visibleNodeIds,
workState, workState,
refreshWorkState, refreshWorkState,
onOpenConversation, onOpenConversation,
@ -448,20 +438,37 @@ function LeafView({
}; };
}; };
/** True when the live session is already displayed by another visible cell. */ /**
const visibleElsewhere = (candidate: string): LiveAgent | undefined => { * The cell that still displays `candidate` in the CURRENT layout, if any: a
const live = liveFor(candidate); * visible leaf other than this one whose pinned agent IS `candidate`.
return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId) *
? live * Ticket #56 — derived from the layout, NOT from `liveAgents[].nodeId`. The
: undefined; * live registry keeps pointing at an agent's LAST host cell even after that
* cell swapped to another agent (or none): reading `live.nodeId` as "displayed
* here" wrongly disabled the agent everywhere else, although it is no longer
* shown anywhere. An agent truly shown in a visible cell stays non-selectable
* elsewhere (singleton display invariant); a stale live association does not.
*/
const visibleElsewhere = (candidate: string): { nodeId: string } | undefined => {
if (!vm.layout) return undefined;
const host = leaves(vm.layout).find(
(leaf) => leaf.id !== id && (leaf.agent ?? null) === candidate,
);
return host ? { nodeId: host.id } : undefined;
}; };
/** A live session whose previous host cell no longer exists in the layout. */ /**
* A live PTY session for `candidate` that this cell can re-attach without
* re-spawning: it exists, is not recorded on THIS cell (`nodeId === id`, so a
* fresh self-launch never triggers a re-attach loop), and is not currently
* displayed by another visible cell ({@link visibleElsewhere} — the singleton
* double-display case). A session whose former host cell now shows a different
* agent (or none) is a background session → selectable here (ticket #56).
*/
const backgroundLive = (candidate: string): LiveAgent | undefined => { const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate); const live = liveFor(candidate);
return live && live.kind === "pty" && live.nodeId !== id && !visibleNodeIds.has(live.nodeId) if (!live || live.kind !== "pty" || live.nodeId === id) return undefined;
? live return visibleElsewhere(candidate) ? undefined : live;
: undefined;
}; };
// A transient notice shown when an action is blocked by the singleton // A transient notice shown when an action is blocked by the singleton
@ -665,21 +672,20 @@ function LeafView({
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents) ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents; : liveAgents;
setLiveAgents(current); setLiveAgents(current);
const live = current.find((la) => la.agentId === val); // Visibility is derived from the CURRENT layout, not the live
const isVisible = // registry's stale last-known nodeId (ticket #56): the agent is
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId); // "visible ailleurs" only while a visible cell still pins it.
if (isVisible) { const elsewhere = visibleElsewhere(val);
if (elsewhere) {
setBusyNotice({ setBusyNotice({
message: "Cet agent est déjà actif dans une autre cellule.", message: "Cet agent est déjà actif dans une autre cellule.",
goToNodeId: live!.nodeId, goToNodeId: elsewhere.nodeId,
}); });
return; return;
} }
const live = current.find((la) => la.agentId === val);
const isBackground = const isBackground =
live && live && live.kind === "pty" && live.nodeId !== id;
live.kind === "pty" &&
live.nodeId !== id &&
!visibleNodeIds.has(live.nodeId);
if (isBackground) { if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) { if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice({ setBusyNotice({
@ -1143,7 +1149,6 @@ interface SplitViewProps {
cwd: string; cwd: string;
vm: LayoutViewModel; vm: LayoutViewModel;
projectId: string; projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
@ -1154,7 +1159,6 @@ function SplitView({
cwd, cwd,
vm, vm,
projectId, projectId,
visibleNodeIds,
workState, workState,
refreshWorkState, refreshWorkState,
onOpenConversation, onOpenConversation,
@ -1199,7 +1203,6 @@ function SplitView({
cwd={cwd} cwd={cwd}
vm={vm} vm={vm}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}
@ -1294,7 +1297,6 @@ interface GridViewProps {
cwd: string; cwd: string;
vm: LayoutViewModel; vm: LayoutViewModel;
projectId: string; projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void; onOpenConversation?: (conversationId: string) => void;
@ -1305,7 +1307,6 @@ function GridView({
cwd, cwd,
vm, vm,
projectId, projectId,
visibleNodeIds,
workState, workState,
refreshWorkState, refreshWorkState,
onOpenConversation, onOpenConversation,
@ -1346,7 +1347,6 @@ function GridView({
vm={vm} vm={vm}
parentSplit={null} parentSplit={null}
projectId={projectId} projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation} onOpenConversation={onOpenConversation}

View File

@ -274,7 +274,14 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
const { a, b } = await splitInTwo(layout); const { a, b } = await splitInTwo(layout);
// The agent is live in cell B (a currently-visible leaf). // The agent is DISPLAYED in cell B (a currently-visible leaf) and live there.
// "Visible ailleurs" is derived from the layout, so the cell must actually
// pin the agent, not merely have the live registry point at it (ticket #56).
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: b,
agent: ag.id,
});
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" }, { agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" },
]); ]);
@ -328,3 +335,114 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
}); });
}); });
describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale liveAgents.nodeId", () => {
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
const system = {
onDomainEvent: vi.fn().mockResolvedValue(() => {}),
};
const gateways = { layout, agent, system } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
/** Splits the project's single leaf into two visible leaves; returns both ids. */
async function splitInTwo(
layout: MockLayoutGateway,
): Promise<{ a: string; b: string }> {
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
const after = leaves(await layout.loadLayout("p1"));
return { a, b: after.find((l) => l.id !== a)!.id };
}
it("an agent whose former host cell now shows ANOTHER agent is selectable (background, not 'visible ailleurs')", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const x = await agent.createAgent("p1", { name: "Xavier", profileId: "p" });
const y = await agent.createAgent("p1", { name: "Yann", profileId: "p" });
const { a, b } = await splitInTwo(layout);
// X was launched in cell A → the live registry records X → nodeId A.
await agent.launchAgent(
"p1",
x.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: a },
noop,
);
// …then cell A was switched to display agent Y. The live registry still
// points X at A (stale), but A no longer displays X — X is displayed nowhere.
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: a,
agent: y.id,
});
const attach = vi.spyOn(agent, "attachLiveAgent");
renderGrid(layout, agent);
// In cell B's dropdown, X is ENABLED and flagged as a background session —
// NOT "visible ailleurs" (regression: it used to be disabled via the stale
// live nodeId still pointing at the now-visible cell A).
const selectB = (await screen.findByLabelText(
`agent selector ${b}`,
)) as HTMLSelectElement;
await waitFor(() => {
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
expect(opt.disabled).toBe(false);
expect(opt.textContent).toMatch(/arrière-plan/);
expect(opt.textContent).not.toMatch(/visible ailleurs/);
});
// Selecting X in B re-attaches its live session (no re-spawn) and pins it here.
fireEvent.change(selectB, { target: { value: x.id } });
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
expect(leaf.agent).toBe(x.id);
expect(leaf.session).toBe("mock-agent-session-1");
});
expect(attach).toHaveBeenCalledWith("p1", x.id, b);
});
it("an agent STILL pinned in another visible cell stays disabled ('visible ailleurs')", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const x = await agent.createAgent("p1", { name: "Xavier", profileId: "p" });
const { a, b } = await splitInTwo(layout);
// Cell A still displays X (and X is live there): the singleton display
// invariant must keep X non-selectable in cell B.
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: a,
agent: x.id,
});
await agent.launchAgent(
"p1",
x.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: a },
noop,
);
renderGrid(layout, agent);
const selectB = (await screen.findByLabelText(
`agent selector ${b}`,
)) as HTMLSelectElement;
await waitFor(() => {
const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
expect(opt.disabled).toBe(true);
expect(opt.textContent).toMatch(/visible ailleurs/);
});
});
});