This commit is contained in:
@ -745,11 +745,18 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
};
|
};
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// Singleton invariant: refuse a launch when the agent is already live in a
|
// Singleton invariant for the human native TUI surface: refuse a launch when
|
||||||
// *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same
|
// the agent already has a PTY in a different cell. Structured/headless
|
||||||
// node is allowed (idempotent re-launch of the very same cell).
|
// sessions are separate surfaces and must not make the human TUI look
|
||||||
|
// "already active elsewhere" (#169/#170).
|
||||||
const liveNode = this.liveByAgent.get(agentId);
|
const liveNode = this.liveByAgent.get(agentId);
|
||||||
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
|
const liveKind = this.liveKindByAgent.get(agentId) ?? "pty";
|
||||||
|
if (
|
||||||
|
liveNode !== undefined &&
|
||||||
|
liveKind === "pty" &&
|
||||||
|
options.nodeId &&
|
||||||
|
liveNode !== options.nodeId
|
||||||
|
) {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
code: "AGENT_ALREADY_RUNNING",
|
code: "AGENT_ALREADY_RUNNING",
|
||||||
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
||||||
@ -814,7 +821,13 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
} as GatewayError;
|
} as GatewayError;
|
||||||
}
|
}
|
||||||
const liveNode = this.liveByAgent.get(agentId);
|
const liveNode = this.liveByAgent.get(agentId);
|
||||||
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
|
const liveKind = this.liveKindByAgent.get(agentId) ?? "pty";
|
||||||
|
if (
|
||||||
|
liveNode !== undefined &&
|
||||||
|
liveKind === "pty" &&
|
||||||
|
options.nodeId &&
|
||||||
|
liveNode !== options.nodeId
|
||||||
|
) {
|
||||||
throw {
|
throw {
|
||||||
code: "AGENT_ALREADY_RUNNING",
|
code: "AGENT_ALREADY_RUNNING",
|
||||||
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
||||||
|
|||||||
@ -111,8 +111,9 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
||||||
@ -1080,7 +1081,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts pasted images exposed only through clipboardData.files", async () => {
|
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
@ -1121,7 +1122,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const file = new File(["ignored"], "files-only.png", { type: "image/png" });
|
const file = new File(["ignored"], "files-only.pdf", { type: "application/pdf" });
|
||||||
Object.defineProperty(file, "arrayBuffer", {
|
Object.defineProperty(file, "arrayBuffer", {
|
||||||
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
|
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
|
||||||
});
|
});
|
||||||
@ -1133,7 +1134,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByText("Fichier joint: files-only.png");
|
await screen.findByText("Fichier joint: files-only.pdf");
|
||||||
|
expect(screen.queryByTestId("attachment-preview-files-only.pdf")).toBeNull();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
|
||||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||||
@ -1144,9 +1146,9 @@ describe("CustomAgentChatView", () => {
|
|||||||
{
|
{
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
filename: "files-only.png",
|
filename: "files-only.pdf",
|
||||||
contentBase64: "BAUG",
|
contentBase64: "BAUG",
|
||||||
mime: "image/png",
|
mime: "application/pdf",
|
||||||
sourceKind: "clipboard",
|
sourceKind: "clipboard",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -1194,14 +1196,12 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
const scroll = screen.getByTestId("custom-agent-chat-scroll");
|
const scroll = screen.getByTestId("custom-agent-chat-scroll");
|
||||||
const composer = screen.getByTestId("custom-agent-chat-composer");
|
const composer = screen.getByTestId("custom-agent-chat-composer");
|
||||||
const cancel = await screen.findByRole("button", { name: "Cancel" });
|
|
||||||
|
|
||||||
expect(shell.className).toContain("h-full");
|
expect(shell.className).toContain("h-full");
|
||||||
expect(shell.className).toContain("min-h-0");
|
expect(shell.className).toContain("min-h-0");
|
||||||
expect(shell.className).toContain("overflow-hidden");
|
expect(shell.className).toContain("overflow-hidden");
|
||||||
expect(toolbar.className).toContain("overflow-hidden");
|
expect(toolbar.className).toContain("overflow-hidden");
|
||||||
expect(cancel.className).toContain("shrink-0");
|
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||||
expect(cancel.className).toContain("z-30");
|
|
||||||
expect(scroll.className).toContain("flex-1");
|
expect(scroll.className).toContain("flex-1");
|
||||||
expect(scroll.className).toContain("basis-0");
|
expect(scroll.className).toContain("basis-0");
|
||||||
expect(scroll.className).toContain("overflow-y-auto");
|
expect(scroll.className).toContain("overflow-y-auto");
|
||||||
@ -1520,7 +1520,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled());
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled());
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
expect(await screen.findByText("Session déjà fermée.")).toBeTruthy();
|
expect(await screen.findByText("Session déjà fermée.")).toBeTruthy();
|
||||||
expect(onSessionId).toHaveBeenCalledWith(null);
|
expect(onSessionId).toHaveBeenCalledWith(null);
|
||||||
|
|||||||
@ -246,11 +246,13 @@ function bytesToBase64(bytes: Uint8Array): string {
|
|||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clipboardImageToAttachment(file: File): Promise<AttachmentDraft> {
|
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
||||||
const mime = file.type || "application/octet-stream";
|
const mime = file.type || "application/octet-stream";
|
||||||
const filename =
|
const filename =
|
||||||
file.name ||
|
file.name ||
|
||||||
`clipboard-image-${Date.now()}.${fileExtension(mime)}`;
|
(mime.startsWith("image/")
|
||||||
|
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
|
||||||
|
: `clipboard-file-${Date.now()}`);
|
||||||
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||||
return {
|
return {
|
||||||
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
@ -267,7 +269,7 @@ async function clipboardImageToAttachment(file: File): Promise<AttachmentDraft>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||||
const clipboardItems = event.clipboardData.items
|
const clipboardItems = event.clipboardData.items
|
||||||
? Array.from(event.clipboardData.items)
|
? Array.from(event.clipboardData.items)
|
||||||
: [];
|
: [];
|
||||||
@ -275,12 +277,11 @@ function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[]
|
|||||||
? Array.from(event.clipboardData.files)
|
? Array.from(event.clipboardData.files)
|
||||||
: [];
|
: [];
|
||||||
const fromItems = clipboardItems
|
const fromItems = clipboardItems
|
||||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
.filter((item) => item.kind === "file")
|
||||||
.map((item) => item.getAsFile())
|
.map((item) => item.getAsFile())
|
||||||
.filter((file): file is File => Boolean(file));
|
.filter((file): file is File => Boolean(file));
|
||||||
const fromFiles = clipboardFiles.filter((file) => file.type.startsWith("image/"));
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return [...fromItems, ...fromFiles].filter((file) => {
|
return [...fromItems, ...clipboardFiles].filter((file) => {
|
||||||
const key = `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
const key = `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@ -849,10 +850,10 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pasteClipboardImages(
|
async function pasteClipboardFiles(
|
||||||
event: ClipboardEvent<HTMLTextAreaElement>,
|
event: ClipboardEvent<HTMLTextAreaElement>,
|
||||||
) {
|
) {
|
||||||
const files = clipboardImageFiles(event);
|
const files = clipboardFiles(event);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -861,7 +862,7 @@ export function CustomAgentChatView({
|
|||||||
const selectionEnd = event.currentTarget.selectionEnd;
|
const selectionEnd = event.currentTarget.selectionEnd;
|
||||||
try {
|
try {
|
||||||
const nextAttachments = await Promise.all(
|
const nextAttachments = await Promise.all(
|
||||||
files.map(clipboardImageToAttachment),
|
files.map(clipboardFileToAttachment),
|
||||||
);
|
);
|
||||||
setAttachments((prev) => [...prev, ...nextAttachments]);
|
setAttachments((prev) => [...prev, ...nextAttachments]);
|
||||||
if (pastedText) {
|
if (pastedText) {
|
||||||
@ -976,18 +977,6 @@ export function CustomAgentChatView({
|
|||||||
CLI custom · {profile.name}
|
CLI custom · {profile.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
||||||
{(opening || busy) && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
className="relative z-30 shrink-0 whitespace-nowrap shadow-sm"
|
|
||||||
onClick={() => void cancel()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!supported && (
|
{!supported && (
|
||||||
@ -1206,7 +1195,7 @@ export function CustomAgentChatView({
|
|||||||
disabled={!supported || opening || busy}
|
disabled={!supported || opening || busy}
|
||||||
placeholder="Message à l'agent…"
|
placeholder="Message à l'agent…"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onPaste={(e) => void pasteClipboardImages(e)}
|
onPaste={(e) => void pasteClipboardFiles(e)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (slashMenuOpen && slashCommands.length > 0) {
|
if (slashMenuOpen && slashCommands.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
@ -1250,12 +1239,12 @@ export function CustomAgentChatView({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant={busy ? "danger" : undefined}
|
||||||
className="shrink-0 whitespace-nowrap"
|
className="shrink-0 whitespace-nowrap"
|
||||||
disabled={!canSend}
|
disabled={busy ? !supported || !currentSession : !canSend}
|
||||||
loading={busy}
|
onClick={() => (busy ? void cancel() : void send())}
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
>
|
||||||
Envoyer
|
{busy ? "Stop" : "Envoyer"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -179,6 +179,100 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const profileGateway = new MockProfileGateway();
|
||||||
|
const terminal = new MockTerminalGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
await profileGateway.configureProfiles([structuredProfile]);
|
||||||
|
const created = await agent.createAgent("p1", {
|
||||||
|
name: "Worker",
|
||||||
|
profileId: structuredProfile.id,
|
||||||
|
});
|
||||||
|
const tree = await layout.loadLayout("p1");
|
||||||
|
const leafId = leaves(tree)[0].id;
|
||||||
|
await layout.mutateLayout("p1", {
|
||||||
|
type: "setCellAgent",
|
||||||
|
target: leafId,
|
||||||
|
agent: created.id,
|
||||||
|
});
|
||||||
|
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||||
|
await agent.launchAgentChat("p1", created.id, {
|
||||||
|
cwd: "/home/me/proj",
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
nodeId: "structured-background",
|
||||||
|
});
|
||||||
|
const launchChat = vi.spyOn(agent, "launchAgentChat");
|
||||||
|
|
||||||
|
renderGrid({
|
||||||
|
layout,
|
||||||
|
agent,
|
||||||
|
profile: profileGateway,
|
||||||
|
terminal,
|
||||||
|
system,
|
||||||
|
} as unknown as Gateways);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(launchChat).toHaveBeenCalled());
|
||||||
|
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
|
||||||
|
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the structured chat session by id before switching back to native TUI (#170)", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const profileGateway = new MockProfileGateway();
|
||||||
|
const terminal = new MockTerminalGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
await profileGateway.configureProfiles([structuredProfile]);
|
||||||
|
const created = await agent.createAgent("p1", {
|
||||||
|
name: "Worker",
|
||||||
|
profileId: structuredProfile.id,
|
||||||
|
});
|
||||||
|
const tree = await layout.loadLayout("p1");
|
||||||
|
const leafId = leaves(tree)[0].id;
|
||||||
|
await layout.mutateLayout("p1", {
|
||||||
|
type: "setCellAgent",
|
||||||
|
target: leafId,
|
||||||
|
agent: created.id,
|
||||||
|
});
|
||||||
|
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||||
|
const closeChat = vi.spyOn(agent, "closeAgentChat");
|
||||||
|
const stopLive = vi.spyOn(agent, "stopLiveAgent");
|
||||||
|
|
||||||
|
renderGrid({
|
||||||
|
layout,
|
||||||
|
agent,
|
||||||
|
profile: profileGateway,
|
||||||
|
terminal,
|
||||||
|
system,
|
||||||
|
} as unknown as Gateways);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const updated = await layout.loadLayout("p1");
|
||||||
|
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("TUI native"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Arrêter et relancer"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)),
|
||||||
|
);
|
||||||
|
expect(stopLive).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
it("requires confirmation before switching a live native TUI session", async () => {
|
it("requires confirmation before switching a live native TUI session", async () => {
|
||||||
renderGrid(await seeded(structuredProfile));
|
renderGrid(await seeded(structuredProfile));
|
||||||
|
|
||||||
|
|||||||
@ -712,16 +712,12 @@ function LeafView({
|
|||||||
|
|
||||||
async function stopCurrentSessionForSwitch(): Promise<void> {
|
async function stopCurrentSessionForSwitch(): Promise<void> {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
if (agentId && agentGateway?.stopLiveAgent) {
|
if (cellMode === "custom" && agentGateway?.closeAgentChat) {
|
||||||
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
|
|
||||||
if (cellMode === "custom" && agentGateway.closeAgentChat) {
|
|
||||||
await agentGateway.closeAgentChat(session);
|
await agentGateway.closeAgentChat(session);
|
||||||
return;
|
} else if (agentId && agentGateway?.stopLiveAgent) {
|
||||||
}
|
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
|
||||||
await terminal?.closeTerminal(session);
|
await terminal?.closeTerminal(session);
|
||||||
});
|
});
|
||||||
} else if (cellMode === "custom" && agentGateway?.closeAgentChat) {
|
|
||||||
await agentGateway.closeAgentChat(session);
|
|
||||||
} else {
|
} else {
|
||||||
await terminal?.closeTerminal(session);
|
await terminal?.closeTerminal(session);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user