feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
* #3 — Agent dropdown per cell: setCellAgent persists in the layout, and the
|
||||
* terminal re-mounts with the agent opener; "Plain" reverts to a plain terminal.
|
||||
*/
|
||||
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";
|
||||
@ -19,10 +19,11 @@ function renderGrid(
|
||||
layout: MockLayoutGateway,
|
||||
agent: MockAgentGateway,
|
||||
projectId = "p1",
|
||||
terminal: MockTerminalGateway = new MockTerminalGateway(),
|
||||
) {
|
||||
const gateways = {
|
||||
layout,
|
||||
terminal: new MockTerminalGateway(),
|
||||
terminal,
|
||||
agent,
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
@ -134,6 +135,114 @@ describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("changing the agent resets the cell session to null (Bug #3)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||
|
||||
// Pre-set a running session on the leaf (as if a plain terminal had opened).
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "old-session",
|
||||
});
|
||||
|
||||
renderGrid(layout, agent);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
const l = leaves(fresh)[0];
|
||||
expect(l.agent).toBe(a.id);
|
||||
// The stale session must be cleared so the new agent opens fresh.
|
||||
expect(l.session === null || l.session === undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("changing the agent kills the previous PTY (Bug #3)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "old-session",
|
||||
});
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not kill any PTY when the cell had no session (Bug #3)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
expect(leaves(fresh)[0].agent).toBe(a.id);
|
||||
});
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closing a cell kills the dropped cell's PTY (no orphan)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
||||
|
||||
// Split the root into two cells, then give the second a running session.
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const rootId = leaves(tree)[0].id;
|
||||
const afterSplit = await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: rootId,
|
||||
direction: "row",
|
||||
newLeaf: "leaf-b",
|
||||
container: "split-c",
|
||||
});
|
||||
const droppedId = leaves(afterSplit)[1].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: droppedId,
|
||||
session: "dropped-session",
|
||||
});
|
||||
|
||||
renderGrid(layout, agent, "p1", terminal);
|
||||
|
||||
// Close the SECOND cell: keep the first, drop the second → its PTY dies.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeSpy).toHaveBeenCalledWith("dropped-session");
|
||||
});
|
||||
});
|
||||
|
||||
it("agent selector shows project agents as options", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
Reference in New Issue
Block a user