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:
@ -98,8 +98,6 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-grid"
|
||||
@ -116,7 +114,6 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
|
||||
vm={vm}
|
||||
parentSplit={null}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={work.state}
|
||||
refreshWorkState={work.refresh}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -132,7 +129,6 @@ interface NodeViewProps {
|
||||
/** The enclosing split + this node's index in it, for the merge action. */
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -144,7 +140,6 @@ function NodeView({
|
||||
vm,
|
||||
parentSplit,
|
||||
projectId,
|
||||
visibleNodeIds,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
@ -162,7 +157,6 @@ function NodeView({
|
||||
vm={vm}
|
||||
parentSplit={parentSplit}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -175,7 +169,6 @@ function NodeView({
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -188,7 +181,6 @@ function NodeView({
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -207,7 +199,6 @@ interface LeafViewProps {
|
||||
vm: LayoutViewModel;
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -289,7 +280,6 @@ function LeafView({
|
||||
vm,
|
||||
parentSplit,
|
||||
projectId,
|
||||
visibleNodeIds,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
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 => {
|
||||
const live = liveFor(candidate);
|
||||
return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId)
|
||||
? live
|
||||
: undefined;
|
||||
/**
|
||||
* The cell that still displays `candidate` in the CURRENT layout, if any: a
|
||||
* visible leaf other than this one whose pinned agent IS `candidate`.
|
||||
*
|
||||
* Ticket #56 — derived from the layout, NOT from `liveAgents[].nodeId`. The
|
||||
* 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 live = liveFor(candidate);
|
||||
return live && live.kind === "pty" && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
|
||||
? live
|
||||
: undefined;
|
||||
if (!live || live.kind !== "pty" || live.nodeId === id) return undefined;
|
||||
return visibleElsewhere(candidate) ? undefined : live;
|
||||
};
|
||||
|
||||
// A transient notice shown when an action is blocked by the singleton
|
||||
@ -665,21 +672,20 @@ function LeafView({
|
||||
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
|
||||
: liveAgents;
|
||||
setLiveAgents(current);
|
||||
const live = current.find((la) => la.agentId === val);
|
||||
const isVisible =
|
||||
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
|
||||
if (isVisible) {
|
||||
// Visibility is derived from the CURRENT layout, not the live
|
||||
// registry's stale last-known nodeId (ticket #56): the agent is
|
||||
// "visible ailleurs" only while a visible cell still pins it.
|
||||
const elsewhere = visibleElsewhere(val);
|
||||
if (elsewhere) {
|
||||
setBusyNotice({
|
||||
message: "Cet agent est déjà actif dans une autre cellule.",
|
||||
goToNodeId: live!.nodeId,
|
||||
goToNodeId: elsewhere.nodeId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const live = current.find((la) => la.agentId === val);
|
||||
const isBackground =
|
||||
live &&
|
||||
live.kind === "pty" &&
|
||||
live.nodeId !== id &&
|
||||
!visibleNodeIds.has(live.nodeId);
|
||||
live && live.kind === "pty" && live.nodeId !== id;
|
||||
if (isBackground) {
|
||||
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
|
||||
setBusyNotice({
|
||||
@ -1143,7 +1149,6 @@ interface SplitViewProps {
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -1154,7 +1159,6 @@ function SplitView({
|
||||
cwd,
|
||||
vm,
|
||||
projectId,
|
||||
visibleNodeIds,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
@ -1199,7 +1203,6 @@ function SplitView({
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -1294,7 +1297,6 @@ interface GridViewProps {
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -1305,7 +1307,6 @@ function GridView({
|
||||
cwd,
|
||||
vm,
|
||||
projectId,
|
||||
visibleNodeIds,
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
@ -1346,7 +1347,6 @@ function GridView({
|
||||
vm={vm}
|
||||
parentSplit={null}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
|
||||
Reference in New Issue
Block a user