feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -8,7 +8,7 @@
|
||||
* rather than xterm's visual output. They stay robust whether or not xterm
|
||||
* bailed.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
@ -90,8 +90,10 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => {
|
||||
it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const a = leaves(initial)[0].id;
|
||||
// Split A → container c with children [A (index 0), b (index 1)].
|
||||
@ -102,8 +104,18 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: "b",
|
||||
session: "running-session-b",
|
||||
});
|
||||
|
||||
renderGrid(layout);
|
||||
const gateways = { layout, terminal } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
|
||||
);
|
||||
@ -117,6 +129,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
expect(
|
||||
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
|
||||
).toBe(a);
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closing the other cell keeps the opposite sibling", async () => {
|
||||
|
||||
@ -28,7 +28,7 @@ import type {
|
||||
} from "@/ports";
|
||||
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { normalizeWeights, resizeAdjacent } from "./layout";
|
||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||
import { useLayout, type LayoutViewModel } from "./useLayout";
|
||||
|
||||
interface LayoutGridProps {
|
||||
@ -56,6 +56,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -73,6 +74,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
|
||||
vm={vm}
|
||||
parentSplit={null}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -85,9 +87,10 @@ 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>;
|
||||
}
|
||||
|
||||
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
|
||||
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
|
||||
switch (node.type) {
|
||||
case "leaf":
|
||||
return (
|
||||
@ -101,12 +104,29 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
|
||||
vm={vm}
|
||||
parentSplit={parentSplit}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
/>
|
||||
);
|
||||
case "split":
|
||||
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
|
||||
return (
|
||||
<SplitView
|
||||
split={node.node}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
/>
|
||||
);
|
||||
case "grid":
|
||||
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
|
||||
return (
|
||||
<GridView
|
||||
grid={node.node}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +140,7 @@ interface LeafViewProps {
|
||||
vm: LayoutViewModel;
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,7 +156,7 @@ interface PendingResume {
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
|
||||
// A cell can be closed only when it lives inside a (binary) split: closing it
|
||||
// collapses the parent split, keeping the *sibling*. Splits are always binary
|
||||
// in this model (a split wraps a leaf into a 2-child container), so the kept
|
||||
@ -204,13 +225,25 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
// Build the terminal opener based on whether an agent is pinned.
|
||||
const agentId = agent ?? null;
|
||||
|
||||
/**
|
||||
* Whether `candidate` is currently running in a cell *other than this one*.
|
||||
* Such an agent cannot be launched here (one live session per agent), so the
|
||||
* dropdown disables it and `onChange` rejects selecting it.
|
||||
*/
|
||||
const isLiveElsewhere = (candidate: string): boolean =>
|
||||
liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id);
|
||||
/** The live session for `candidate`, if any. */
|
||||
const liveFor = (candidate: string): LiveAgent | undefined =>
|
||||
liveAgents.find((la) => la.agentId === candidate);
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** A live session whose previous host cell no longer exists in the layout. */
|
||||
const backgroundLive = (candidate: string): LiveAgent | undefined => {
|
||||
const live = liveFor(candidate);
|
||||
return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
|
||||
? live
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// A transient notice shown when an action is blocked by the singleton
|
||||
// invariant (selecting / launching an agent already live in another cell).
|
||||
@ -231,6 +264,16 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
convId: string | undefined,
|
||||
): Promise<TerminalHandle> => {
|
||||
const background = backgroundLive(agentId!);
|
||||
if (background?.sessionId && agentGateway!.attachLiveAgent) {
|
||||
const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
|
||||
const sessionId = attached.sessionId ?? background.sessionId;
|
||||
void vm.setSession(id, sessionId);
|
||||
const result = await agentGateway!.reattach(sessionId, onData);
|
||||
refreshLive();
|
||||
return result.handle;
|
||||
}
|
||||
|
||||
const handle = await agentGateway!.launchAgent(
|
||||
projectId,
|
||||
agentId!,
|
||||
@ -328,15 +371,39 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
value={agentId ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Reject pinning an agent that is already running in another cell.
|
||||
// (The dropdown also disables such options, but guard the change in
|
||||
// case it is set programmatically.)
|
||||
if (val !== "" && isLiveElsewhere(val)) {
|
||||
setBusyNotice("Cet agent tourne déjà dans une autre cellule.");
|
||||
if (val === "") {
|
||||
setBusyNotice(null);
|
||||
void vm.setCellAgent(id, null);
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
void vm.setCellAgent(id, val === "" ? null : val);
|
||||
void (async () => {
|
||||
const current = agentGateway?.listLiveAgents
|
||||
? 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) {
|
||||
setBusyNotice("Cet agent est déjà visible dans une autre cellule.");
|
||||
return;
|
||||
}
|
||||
const isBackground =
|
||||
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
|
||||
if (isBackground) {
|
||||
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
|
||||
setBusyNotice("Session active introuvable pour cet agent.");
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
|
||||
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
|
||||
refreshLive();
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
await vm.setCellAgent(id, val);
|
||||
})().catch((err: unknown) => setBusyNotice(describeNotice(err)));
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
@ -350,13 +417,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
{agents.map((a) => {
|
||||
// An agent already running in another cell cannot be pinned here.
|
||||
// The agent pinned on THIS cell stays selectable (same node).
|
||||
const elsewhere = isLiveElsewhere(a.id);
|
||||
const elsewhere = visibleElsewhere(a.id);
|
||||
const background = backgroundLive(a.id);
|
||||
return (
|
||||
<option key={a.id} value={a.id} disabled={elsewhere}>
|
||||
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
|
||||
{a.name}
|
||||
{elsewhere ? " (en cours ailleurs)" : ""}
|
||||
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@ -437,9 +503,10 @@ interface SplitViewProps {
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
|
||||
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
|
||||
const isRow = split.direction === "row";
|
||||
const baseWeights = split.children.map((c) => c.weight);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -480,6 +547,7 @@ function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
parentSplit={{
|
||||
container: split.id,
|
||||
index: i,
|
||||
@ -571,9 +639,10 @@ interface GridViewProps {
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
visibleNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
|
||||
function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
|
||||
const cols = normalizeWeights(grid.colWeights)
|
||||
.map((p) => `${p}fr`)
|
||||
.join(" ");
|
||||
@ -604,7 +673,14 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
|
||||
<NodeView
|
||||
node={cell.node}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
parentSplit={null}
|
||||
projectId={projectId}
|
||||
visibleNodeIds={visibleNodeIds}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -615,3 +691,10 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
|
||||
function keyOf(node: LayoutNode, fallback: number): string {
|
||||
return node.node.id ?? String(fallback);
|
||||
}
|
||||
|
||||
function describeNotice(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
@ -232,15 +232,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
|
||||
// Close the SECOND cell: keep the first, drop the second → its PTY dies.
|
||||
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
|
||||
// now a detach-only view operation; it must not kill the PTY.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalledWith("dropped-session");
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
|
||||
});
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("agent selector shows project agents as options", async () => {
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
* - The mock agent gateway mirrors the backend guard: launching an agent already
|
||||
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
|
||||
* node is idempotent; `listLiveAgents` reports who runs where (T5).
|
||||
* - The per-cell dropdown disables (and `onChange` rejects) an agent already live
|
||||
* in another cell, while the agent pinned on its own cell stays selectable (T6).
|
||||
* - The per-cell dropdown blocks an agent already visible in another cell, but
|
||||
* can rebind a background live session when the backend exposes its session id.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways, LiveAgent } from "@/ports";
|
||||
import {
|
||||
@ -65,7 +65,13 @@ describe("singleton agent — mock gateway guard (T5)", () => {
|
||||
);
|
||||
|
||||
const live: LiveAgent[] = await agent.listLiveAgents("p1");
|
||||
expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]);
|
||||
expect(live).toEqual([
|
||||
{
|
||||
agentId: a.id,
|
||||
nodeId: "node-A",
|
||||
sessionId: "mock-agent-session-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
|
||||
@ -104,34 +110,68 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("disables an agent already running in another cell and rejects selecting it", async () => {
|
||||
it("opens an agent already running elsewhere by rebinding its live session", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
||||
await agent.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" },
|
||||
noop,
|
||||
);
|
||||
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// The option is selectable: choosing it rebinds the running session here.
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
const leaf = leaves(updated).find((l) => l.id === leafId)!;
|
||||
expect(leaf.agent).toBe(a.id);
|
||||
expect(leaf.session).toBe("mock-agent-session-1");
|
||||
});
|
||||
expect(await agent.listLiveAgents("p1")).toEqual([
|
||||
{
|
||||
agentId: a.id,
|
||||
nodeId: leafId,
|
||||
sessionId: "mock-agent-session-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("explains the missing backend contract when a live agent has no attachable session", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
||||
|
||||
// Mark the agent live in a DIFFERENT node than the (single) leaf we render.
|
||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||
{ agentId: a.id, nodeId: "some-other-node" },
|
||||
]);
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// The option for the live-elsewhere agent is rendered disabled with a label.
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy \(en cours ailleurs\)/,
|
||||
name: /Busy/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(true);
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
// Programmatically selecting it must be rejected (agent not pinned).
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const setCellAgentSpy = vi.spyOn(layout, "mutateLayout");
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
|
||||
// A notice is shown and no setCellAgent mutation was issued.
|
||||
expect(await screen.findByRole("status")).toBeTruthy();
|
||||
expect(
|
||||
setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"),
|
||||
).toHaveLength(0);
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||
/Session active introuvable/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
|
||||
|
||||
@ -17,7 +17,7 @@ import type {
|
||||
LayoutTree,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { droppedSessions, leaves, splitOp } from "./layout";
|
||||
import { leaves, splitOp } from "./layout";
|
||||
|
||||
/** What the layout grid UI needs from this hook. */
|
||||
export interface LayoutViewModel {
|
||||
@ -39,6 +39,12 @@ export interface LayoutViewModel {
|
||||
setSession: (target: string, session: string | null) => Promise<void>;
|
||||
/** Pins or clears an agent on a cell (persisted in layout). */
|
||||
setCellAgent: (target: string, agent: string | null) => Promise<void>;
|
||||
/** Shows an already-running agent session in a cell without respawning it. */
|
||||
attachLiveAgentToCell: (
|
||||
target: string,
|
||||
agent: string,
|
||||
session: string,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Records (or clears) the persistent CLI conversation id on a cell (T4b). Used
|
||||
* to persist the id assigned at first launch so the next open resumes it.
|
||||
@ -134,23 +140,12 @@ export function useLayout(
|
||||
);
|
||||
const merge = useCallback(
|
||||
async (container: string, keepIndex: number) => {
|
||||
// Closing a cell collapses its split onto the kept child; the dropped
|
||||
// child's PTYs must be killed (not just detached) or they'd linger as
|
||||
// orphan agent processes. Snapshot the sessions to drop BEFORE mutating
|
||||
// (the tree no longer holds them afterwards), then tear them down.
|
||||
const dropped = layout ? droppedSessions(layout, container, keepIndex) : [];
|
||||
// Closing a cell is a VIEW operation: it collapses the layout, but the
|
||||
// dropped cell's PTY keeps running. TerminalView cleanup detaches the local
|
||||
// stream; the agent/session can later be re-opened in a cell via IdeA.
|
||||
await mutate({ type: "merge", container, keepIndex });
|
||||
if (terminal) {
|
||||
for (const session of dropped) {
|
||||
try {
|
||||
await terminal.closeTerminal(session);
|
||||
} catch {
|
||||
/* already gone — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[layout, mutate, terminal],
|
||||
[mutate],
|
||||
);
|
||||
const resize = useCallback(
|
||||
(container: string, weights: number[]) =>
|
||||
@ -169,8 +164,10 @@ export function useLayout(
|
||||
const setCellAgent = useCallback(
|
||||
async (target: string, agent: string | null) => {
|
||||
// Find the cell's current agent + session. Changing the agent must reset
|
||||
// the session (so the remounted TerminalView opens fresh for the new agent
|
||||
// instead of reattaching to the old PTY) and kill the old PTY.
|
||||
// the session so the remounted TerminalView opens/attaches for the new
|
||||
// agent instead of reattaching to the old PTY. If the previous session was
|
||||
// an agent session, it is only detached into the background; a cell is a
|
||||
// view, not the process owner.
|
||||
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
|
||||
const oldAgent = cell?.agent ?? null;
|
||||
const oldSession = cell?.session ?? null;
|
||||
@ -184,8 +181,37 @@ export function useLayout(
|
||||
{ type: "setCellConversation", target, conversationId: null },
|
||||
]);
|
||||
|
||||
// Best-effort: tear down the previous PTY so no orphan process lingers.
|
||||
if (oldSession && terminal) {
|
||||
// Best-effort: tear down a previous plain terminal so no orphan shell
|
||||
// lingers. Agent sessions keep running in the background.
|
||||
if (oldSession && !oldAgent && terminal) {
|
||||
try {
|
||||
await terminal.closeTerminal(oldSession);
|
||||
} catch {
|
||||
/* already gone — ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
[layout, mutateChain, terminal],
|
||||
);
|
||||
const attachLiveAgentToCell = useCallback(
|
||||
async (target: string, agent: string, session: string) => {
|
||||
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
|
||||
const oldAgent = cell?.agent ?? null;
|
||||
const oldSession = cell?.session ?? null;
|
||||
|
||||
const operations: LayoutOperation[] = [];
|
||||
if (agent !== oldAgent) {
|
||||
operations.push(
|
||||
{ type: "setCellAgent", target, agent },
|
||||
{ type: "setCellConversation", target, conversationId: null },
|
||||
);
|
||||
}
|
||||
if (session !== oldSession) {
|
||||
operations.push({ type: "setSession", target, session });
|
||||
}
|
||||
await mutateChain(operations);
|
||||
|
||||
if (oldSession && oldSession !== session && !oldAgent && terminal) {
|
||||
try {
|
||||
await terminal.closeTerminal(oldSession);
|
||||
} catch {
|
||||
@ -212,6 +238,7 @@ export function useLayout(
|
||||
move,
|
||||
setSession,
|
||||
setCellAgent,
|
||||
attachLiveAgentToCell,
|
||||
setCellConversation,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user