feat(chat): resize vertical du composer avec poignée haut-droit (modèle carnet ticket) — #193

This commit is contained in:
2026-08-14 09:33:05 +02:00
parent d0521db6b6
commit 2b2da223d5
5 changed files with 642 additions and 544 deletions

View File

@ -1681,6 +1681,7 @@ export type ReplyProgressStage = "started" | "delta" | "completed" | "info";
export interface ReplyProgress { export interface ReplyProgress {
progressId?: string; progressId?: string;
occurredAtMs?: number;
source: ReplyProgressSource; source: ReplyProgressSource;
kind: ReplyProgressKind; kind: ReplyProgressKind;
stage: ReplyProgressStage; stage: ReplyProgressStage;

View File

@ -78,7 +78,7 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests(); __resetProjectCliInteractionActivityForTests();
}); });
it("repaints compact attachment refs from legacy user prompt scrollback", async () => { it("repaints attachment labels from user prompt scrollback", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ reattachAgentChat: vi.fn(async (sessionId: string) => ({
@ -119,68 +119,91 @@ describe("CustomAgentChatView", () => {
); );
expect(await screen.findByText("describe this")).toBeTruthy(); expect(await screen.findByText("describe this")).toBeTruthy();
expect(screen.getByText("[image 1] clipboard.png")).toBeTruthy(); expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
}); });
it("prefers canonical attachment refs from user prompt scrollback", async () => { it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({
x: 0,
y: 0,
width: 600,
height: 400,
top: 0,
right: 600,
bottom: 400,
left: 0,
toJSON: () => {},
} as DOMRect);
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId, sessionId,
scrollback: [ scrollback: [],
{
kind: "userPrompt" as const,
text: "compare these",
attachments: ["screenshot.png", "error.log"],
attachmentRefs: [
{
reference: "[image 1]",
filename: "screenshot.png",
mime: "image/png",
sizeBytes: 12,
readablePath: "/repo/.ideai/attachments/screenshot.png",
},
{
reference: "[file 1]",
filename: "error.log",
mime: "text/plain",
sizeBytes: 34,
readablePath: "/repo/.ideai/attachments/error.log",
},
],
},
],
})), })),
sendAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}),
}; };
render( function Harness() {
<DIProvider const [height, setHeight] = useState(64);
gateways={{ return (
agent, <DIProvider
system: { pickFile: vi.fn(async () => null) }, gateways={{
} as unknown as Gateways} agent,
> system: { pickFile: vi.fn(async () => null) },
<CustomAgentChatView } as unknown as Gateways}
projectId="project-1" >
agentId="agent-1" <CustomAgentChatView
agentName="Worker" projectId="project-1"
profile={profile} agentId="agent-1"
cwd="/repo" agentName="Worker"
nodeId="node-1" profile={profile}
sessionId="chat-session-1" cwd="/repo"
conversationId="conversation-1" nodeId="node-1"
onSessionId={vi.fn()} sessionId="chat-session-1"
onConversationId={vi.fn()} conversationId="conversation-1"
/> composerHeightPx={height}
</DIProvider>, composerDefaultHeightPx={64}
); onComposerHeightPxChange={setHeight}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>
);
}
expect(await screen.findByText("compare these")).toBeTruthy(); try {
expect(screen.getByText("[image 1] screenshot.png")).toBeTruthy(); render(<Harness />);
expect(screen.getByText("[file 1] error.log")).toBeTruthy(); const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: "redimensionner le composer node-1",
});
expect(composer.style.height).toBe("64px");
expect(handle.style.cursor).toBe("ns-resize");
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(window, { clientY: 100 });
fireEvent.mouseUp(window);
await waitFor(() => expect(composer.style.height).toBe("164px"));
fireEvent.keyDown(handle, { key: "ArrowUp", shiftKey: true });
await waitFor(() => expect(composer.style.height).toBe("176px"));
fireEvent.keyDown(handle, { key: "ArrowDown", shiftKey: true });
await waitFor(() => expect(composer.style.height).toBe("164px"));
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(window, { clientY: -200 });
fireEvent.mouseUp(window);
await waitFor(() => expect(composer.style.height).toBe("200px"));
fireEvent.doubleClick(handle);
await waitFor(() => expect(composer.style.height).toBe("64px"));
} finally {
rectSpy.mockRestore();
}
}); });
it("detaches on unmount then reattaches with missed scrollback chunks", async () => { it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
@ -895,7 +918,7 @@ describe("CustomAgentChatView", () => {
}); });
expect(screen.getByText("Worker → QA")).toBeTruthy(); expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started"); expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("En cours");
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
act(() => { act(() => {
@ -909,8 +932,8 @@ describe("CustomAgentChatView", () => {
}); });
await screen.findByText("done"); await screen.findByText("done");
expect(screen.getAllByText("Worker ← QA")).toHaveLength(2); expect(screen.getAllByText("Worker ← QA")).toHaveLength(1);
expect(screen.getAllByText("validate")).toHaveLength(2); expect(screen.getAllByText("validate")).toHaveLength(1);
expect(screen.queryByText("Appel inter-agent")).toBeNull(); expect(screen.queryByText("Appel inter-agent")).toBeNull();
expect(screen.queryByText("reply delivered")).toBeNull(); expect(screen.queryByText("reply delivered")).toBeNull();
expect(screen.queryByText("Tool")).toBeNull(); expect(screen.queryByText("Tool")).toBeNull();
@ -1072,7 +1095,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Downloading 10%")).toBeNull(); expect(screen.queryByText("Downloading 10%")).toBeNull();
expect(screen.getByText("Downloading 75%")).toBeTruthy(); expect(screen.getByText("Downloading 75%")).toBeTruthy();
expect(screen.getByText("Tool").parentElement?.textContent).toContain("running"); expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
}); });
it("renders progress immediately when the chunk callback fires", async () => { it("renders progress immediately when the chunk callback fires", async () => {
@ -1149,6 +1172,97 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
}); });
it("upserts progress by progressId and keeps first emission chronological order", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "run tools" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (
progressId: string,
occurredAtMs: number,
stage: "started" | "completed",
text: string,
) => ({
kind: "progress" as const,
progress: {
progressId,
occurredAtMs,
source: "providerNative" as const,
kind: "tool" as const,
stage,
label: "shell",
toolName: "shell",
text,
},
});
act(() => {
emitChunk?.(progress("process-2", 20, "started", "Process 2 start"));
emitChunk?.(progress("process-1", 10, "started", "Process 1 start"));
emitChunk?.(progress("process-1", 10, "completed", "Process 1 done"));
});
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
expect(screen.queryByText("Process 1 start")).toBeNull();
expect(screen.getByText("Process 1 done")).toBeTruthy();
const first = screen.getByText("Process 1 done");
const second = screen.getByText("Process 2 start");
expect(
Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
it("moves completed entity progress from active map to turns", async () => { it("moves completed entity progress from active map to turns", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null; let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = { const agent = {
@ -1225,7 +1339,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Searching...")).toBeNull(); expect(screen.queryByText("Searching...")).toBeNull();
expect(screen.getByText("Found results")).toBeTruthy(); expect(screen.getByText("Found results")).toBeTruthy();
expect(screen.getByText("Tool").parentElement?.textContent).toContain("done"); expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé");
act(() => { act(() => {
emitChunk?.({ kind: "final", content: "done" }); emitChunk?.({ kind: "final", content: "done" });
@ -1306,7 +1420,7 @@ describe("CustomAgentChatView", () => {
}); });
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running"); expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
act(() => { act(() => {
emitChunk?.({ kind: "final", content: "done" }); emitChunk?.({ kind: "final", content: "done" });
@ -1317,93 +1431,7 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(), expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
); );
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé");
});
it("updates message progress by progressId instead of appending running and done rows", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "ship progress ids" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (stage: "delta" | "completed", text: string) => ({
kind: "progress" as const,
progress: {
progressId: "codex:message:item-1",
source: "providerNative" as const,
kind: "message" as const,
stage,
label: "Analyse",
text,
},
});
act(() => {
emitChunk?.(progress("delta", "Lecture du contexte disponible."));
});
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
act(() => {
emitChunk?.(progress("completed", "Contexte lu."));
});
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
expect(screen.queryByText("Lecture du contexte disponible.")).toBeNull();
expect(screen.getByText("Contexte lu.")).toBeTruthy();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done");
}); });
it("does not append late progress chunks after the final answer", async () => { it("does not append late progress chunks after the final answer", async () => {
@ -2123,7 +2151,8 @@ describe("CustomAgentChatView", () => {
Object.defineProperty(file, "arrayBuffer", { Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
}); });
fireEvent.paste(screen.getByLabelText(/message CLI custom/), { const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.paste(composer, {
clipboardData: { clipboardData: {
items: [ items: [
{ {
@ -2138,12 +2167,14 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy(); expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy(); expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
await waitFor(() => expect(composer.value).toBe("[Image 1]"));
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" })); fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
await waitFor(() => await waitFor(() =>
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(), expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
); );
expect(composer.value).toBe("");
expect( expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement) (screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled, .disabled,
@ -2223,7 +2254,7 @@ describe("CustomAgentChatView", () => {
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith( expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1", "chat-session-1",
"", "[Image 1]",
expect.any(Function), expect.any(Function),
{ {
attachments: [ attachments: [
@ -2389,7 +2420,7 @@ describe("CustomAgentChatView", () => {
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith( expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1", "chat-session-1",
"", "[Image 1]",
expect.any(Function), expect.any(Function),
{ {
attachments: [ attachments: [
@ -2402,8 +2433,8 @@ describe("CustomAgentChatView", () => {
], ],
}, },
); );
expect(screen.getByText("Pièce jointe")).toBeTruthy(); expect(screen.getByText("[Image 1]")).toBeTruthy();
expect(screen.getByText("[image 1] paste-image.png")).toBeTruthy(); expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
}); });
it("accepts pasted generic files exposed only through clipboardData.files", async () => { it("accepts pasted generic files exposed only through clipboardData.files", async () => {
@ -2564,7 +2595,91 @@ describe("CustomAgentChatView", () => {
}, },
); );
expect(screen.getByText("prefix inserted")).toBeTruthy(); expect(screen.getByText("prefix inserted")).toBeTruthy();
expect(screen.getByText("[file 1] mixed.txt")).toBeTruthy(); expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy();
});
it("inserts pasted image tokens at the cursor and sends the tokenized projection", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "voir maintenant" } });
composer.setSelectionRange(5, 5);
const file = new File(["ignored"], "cursor.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(composer, {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: cursor.png");
await waitFor(() => expect(composer.value).toBe("voir [Image 1] maintenant"));
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"voir [Image 1] maintenant",
expect.any(Function),
{
attachments: [
{
filename: "cursor.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
}); });
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => { it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => {
@ -2826,13 +2941,13 @@ describe("CustomAgentChatView", () => {
}); });
await screen.findByText("Fichier joint: not-persisted.png"); await screen.findByText("Fichier joint: not-persisted.png");
await waitFor(() => await waitFor(() =>
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"), expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file[Image 1]"),
); );
first.unmount(); first.unmount();
renderView(); renderView();
await screen.findByDisplayValue("draft with file"); await screen.findByDisplayValue("draft with file[Image 1]");
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull(); expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
}); });

View File

@ -13,11 +13,12 @@ import {
useRef, useRef,
useState, useState,
type ClipboardEvent, type ClipboardEvent,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react"; } from "react";
import type { import type {
AgentProfile, AgentProfile,
ChatPromptAttachment,
GatewayError, GatewayError,
ReplyChunk, ReplyChunk,
ReplyProgress, ReplyProgress,
@ -46,12 +47,15 @@ export interface CustomAgentChatViewProps {
nodeId: string; nodeId: string;
sessionId: string | null; sessionId: string | null;
conversationId: string | null; conversationId: string | null;
composerHeightPx?: number;
composerDefaultHeightPx?: number;
onComposerHeightPxChange?: (heightPx: number) => void;
onSessionId: (sessionId: string | null) => void; onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void; onConversationId: (conversationId: string | null) => void;
} }
type ChatTurn = type ChatTurn =
| { role: "user"; text: string; attachments?: ChatPromptAttachment[] } | { role: "user"; text: string; attachments?: string[] }
| { role: "agent"; text: string; pending?: boolean } | { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string } | { role: "tool"; label: string }
| { role: "progress"; progress: ReplyProgress; settled?: boolean } | { role: "progress"; progress: ReplyProgress; settled?: boolean }
@ -61,12 +65,14 @@ type ChatTurn =
interface CachedChatView { interface CachedChatView {
turns: ChatTurn[]; turns: ChatTurn[];
entityProgressMap: Map<string, ReplyProgress>; progressIndex: Map<string, ProgressIndexEntry>;
chunkCount: number; chunkCount: number;
} }
const chatSessionViewCache = new Map<string, CachedChatView>(); const chatSessionViewCache = new Map<string, CachedChatView>();
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
export function __clearCustomAgentChatViewCacheForTests() { export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear(); chatSessionViewCache.clear();
@ -78,6 +84,12 @@ interface AttachmentDraft {
input: ChatAttachmentInput; input: ChatAttachmentInput;
previewUrl?: string; previewUrl?: string;
dedupeKey?: string; dedupeKey?: string;
token?: string;
}
interface ProgressIndexEntry {
firstOccurredAtMs: number;
sequence: number;
} }
interface ProfileCommandDialogState { interface ProfileCommandDialogState {
@ -159,62 +171,10 @@ function attachmentLabels(value: unknown): string[] {
: []; : [];
} }
function isImageFilename(filename: string): boolean {
return /\.(apng|avif|gif|jpe?g|png|svg|webp)$/i.test(filename.trim());
}
function fallbackAttachmentRefs(labels: string[]): ChatPromptAttachment[] {
let imageCount = 0;
let fileCount = 0;
return labels.map((filename) => {
const image = isImageFilename(filename);
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
return { reference, filename };
});
}
function attachmentRefs(value: unknown, labels: string[] = []): ChatPromptAttachment[] {
if (Array.isArray(value)) {
const refs = value
.map((item): ChatPromptAttachment | null => {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
const reference = optionalString(record.reference);
const filename = optionalString(record.filename);
if (!reference || !filename) return null;
return {
reference,
filename,
mime: optionalString(record.mime),
sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : undefined,
readablePath: optionalString(record.readablePath),
};
})
.filter((item): item is ChatPromptAttachment => Boolean(item));
if (refs.length > 0) return refs;
}
return fallbackAttachmentRefs(labels);
}
function attachmentRefsFromDrafts(attachments: AttachmentDraft[]): ChatPromptAttachment[] {
let imageCount = 0;
let fileCount = 0;
return attachments.map((attachment) => {
const mime = attachment.input.mime ?? "";
const image = mime.startsWith("image/") || (!mime && isImageFilename(attachment.label));
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
return {
reference,
filename: attachment.label,
mime: attachment.input.mime,
};
});
}
function appendUserPrompt( function appendUserPrompt(
turns: ChatTurn[], turns: ChatTurn[],
text: string, text: string,
attachments: ChatPromptAttachment[] = [], attachments: string[] = [],
): ChatTurn[] { ): ChatTurn[] {
const last = turns[turns.length - 1]; const last = turns[turns.length - 1];
if ( if (
@ -268,6 +228,10 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
if (!label) return null; if (!label) return null;
return { return {
progressId: optionalString(record.progressId), progressId: optionalString(record.progressId),
occurredAtMs:
typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs)
? record.occurredAtMs
: undefined,
source: isReplyProgressSource(record.source) ? record.source : "providerNative", source: isReplyProgressSource(record.source) ? record.source : "providerNative",
kind: isReplyProgressKind(record.kind) ? record.kind : "other", kind: isReplyProgressKind(record.kind) ? record.kind : "other",
stage: isReplyProgressStage(record.stage) ? record.stage : "info", stage: isReplyProgressStage(record.stage) ? record.stage : "info",
@ -281,7 +245,6 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
function progressKey(progress: ReplyProgress): string { function progressKey(progress: ReplyProgress): string {
return [ return [
progress.progressId ?? "",
progress.source, progress.source,
progress.kind, progress.kind,
progress.stage, progress.stage,
@ -291,6 +254,14 @@ function progressKey(progress: ReplyProgress): string {
].join("\u0000"); ].join("\u0000");
} }
function progressUpsertKey(progress: ReplyProgress): string {
const progressId = progress.progressId?.trim();
if (progressId) return progressId;
const entity = progressEntityKey(progress);
if (entity) return `legacy-entity:${entity}`;
return `legacy:${progressDisplayKey(progress)}`;
}
function normalizedProgressToolName(progress: ReplyProgress): string | null { function normalizedProgressToolName(progress: ReplyProgress): string | null {
const name = progress.toolName ?? progress.label; const name = progress.toolName ?? progress.label;
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
@ -303,7 +274,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean {
} }
function progressEntityKey(progress: ReplyProgress): string | null { function progressEntityKey(progress: ReplyProgress): string | null {
if (progress.progressId) return `id:${progress.progressId}`;
const name = normalizedProgressToolName(progress); const name = normalizedProgressToolName(progress);
if (!name) return null; if (!name) return null;
if (isDelegationProgress(progress)) return `delegation:${name}`; if (isDelegationProgress(progress)) return `delegation:${name}`;
@ -312,7 +282,7 @@ function progressEntityKey(progress: ReplyProgress): string | null {
} }
function progressStageBucket(progress: ReplyProgress): string { function progressStageBucket(progress: ReplyProgress): string {
if (!progressEntityKey(progress)) return progress.stage; if (!isCenteredToolProgress(progress)) return progress.stage;
if (progress.stage === "started" || progress.stage === "delta") return "started"; if (progress.stage === "started" || progress.stage === "delta") return "started";
if (progress.stage === "completed") return "completed"; if (progress.stage === "completed") return "completed";
return "info"; return "info";
@ -326,54 +296,61 @@ function progressDisplayKey(progress: ReplyProgress): string {
interface ChatViewState { interface ChatViewState {
turns: ChatTurn[]; turns: ChatTurn[];
entityProgressMap: Map<string, ReplyProgress>; progressIndex: Map<string, ProgressIndexEntry>;
} }
function emptyChatViewState(): ChatViewState { function emptyChatViewState(): ChatViewState {
return { turns: [], entityProgressMap: new Map() }; return { turns: [], progressIndex: new Map() };
} }
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
const entity = progressEntityKey(progress); const key = progressUpsertKey(progress);
if (entity) { const existingIndex = state.turns.findIndex(
const bucket = progressStageBucket(progress); (turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key,
const key = `${entity}\u0000${bucket}`; );
if (bucket === "started") { const existingProgress =
const nextMap = new Map(state.entityProgressMap); existingIndex >= 0 && state.turns[existingIndex].role === "progress"
nextMap.set(key, progress); ? state.turns[existingIndex].progress
return { turns: state.turns, entityProgressMap: nextMap }; : null;
} const merged =
if (bucket === "completed") { existingProgress && progress.stage === "completed"
const runningKey = `${entity}\u0000started`; ? mergeCompletedProgress(existingProgress, progress)
const running = state.entityProgressMap.get(runningKey); : progress;
const nextMap = new Map(state.entityProgressMap); const nextIndex = new Map(state.progressIndex);
nextMap.delete(runningKey); const existingEntry = nextIndex.get(key);
if ( const firstOccurredAtMs =
!running && existingEntry?.firstOccurredAtMs ??
state.turns.some( progress.occurredAtMs ??
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, Date.now();
) const sequence = existingEntry?.sequence ?? nextIndex.size;
) { nextIndex.set(key, { firstOccurredAtMs, sequence });
return { turns: state.turns, entityProgressMap: nextMap };
} if (existingIndex >= 0) {
const completed = running ? mergeCompletedProgress(running, progress) : progress; const nextTurns = [...state.turns];
return { nextTurns[existingIndex] = { role: "progress", progress: merged };
turns: [...state.turns, { role: "progress", progress: completed }], return { turns: nextTurns, progressIndex: nextIndex };
entityProgressMap: nextMap,
};
}
} }
const key = progressDisplayKey(progress);
if ( const insertAt = state.turns.findIndex((turn) => {
state.turns.some( if (turn.role !== "progress") return false;
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, const candidate = state.progressIndex.get(progressUpsertKey(turn.progress));
) if (!candidate) return false;
) { if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
return state; return candidate.firstOccurredAtMs > firstOccurredAtMs;
}
return candidate.sequence > sequence;
});
const nextTurn = { role: "progress" as const, progress: merged };
if (insertAt < 0) {
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
} }
return { return {
turns: [...state.turns, { role: "progress", progress }], turns: [
entityProgressMap: state.entityProgressMap, ...state.turns.slice(0, insertAt),
nextTurn,
...state.turns.slice(insertAt),
],
progressIndex: nextIndex,
}; };
} }
@ -430,13 +407,9 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
} }
function completeChatViewProgress(state: ChatViewState): ChatViewState { function completeChatViewProgress(state: ChatViewState): ChatViewState {
const activeTurns = Array.from(state.entityProgressMap.values()).map((progress) => ({
role: "progress" as const,
progress,
}));
return { return {
turns: completeRunningProgress([...state.turns, ...activeTurns]), turns: completeRunningProgress(state.turns),
entityProgressMap: new Map(), progressIndex: state.progressIndex,
}; };
} }
@ -445,7 +418,6 @@ function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
} }
function turnsIndicateBusy(state: ChatViewState): boolean { function turnsIndicateBusy(state: ChatViewState): boolean {
if (state.entityProgressMap.size > 0) return true;
for (let index = state.turns.length - 1; index >= 0; index -= 1) { for (let index = state.turns.length - 1; index >= 0; index -= 1) {
const turn = state.turns[index]; const turn = state.turns[index];
if (turn.role === "final" || turn.role === "error") return false; if (turn.role === "final" || turn.role === "error") return false;
@ -461,6 +433,26 @@ function turnsIndicateBusy(state: ChatViewState): boolean {
return false; return false;
} }
function insertTextAtSelection(
text: string,
insertion: string,
selectionStart: number,
selectionEnd: number,
): string {
return text.slice(0, selectionStart) + insertion + text.slice(selectionEnd);
}
function removeAttachmentToken(text: string, token: string): string {
const index = text.indexOf(token);
if (index < 0) return text;
const before = text.slice(0, index);
const after = text.slice(index + token.length);
if (/\s$/.test(before) && /^\s/.test(after)) {
return before + after.replace(/^\s+/, "");
}
return before + after;
}
function fileExtension(mime: string): string { function fileExtension(mime: string): string {
if (mime === "image/png") return "png"; if (mime === "image/png") return "png";
if (mime === "image/jpeg") return "jpg"; if (mime === "image/jpeg") return "jpg";
@ -584,7 +576,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
} }
const last = turns[turns.length - 1]; const last = turns[turns.length - 1];
@ -602,19 +594,19 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
case "textDelta": case "textDelta":
return { return {
turns: appendAgentDelta(turns, String(raw.text ?? "")), turns: appendAgentDelta(turns, String(raw.text ?? "")),
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
case "toolActivity": case "toolActivity":
return { return {
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }], turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }],
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
case "progress": { case "progress": {
const progress = normalizeProgress(raw.progress); const progress = normalizeProgress(raw.progress);
if (!progress) { if (!progress) {
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
} }
return appendProgress(state, progress); return appendProgress(state, progress);
@ -626,7 +618,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
const last = next[next.length - 1]; const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "final", text: content }); next.push({ role: "final", text: content });
return { turns: next, entityProgressMap: completedState.entityProgressMap }; return { turns: next, progressIndex: completedState.progressIndex };
} }
case "error": { case "error": {
const completedState = completeChatViewProgress(state); const completedState = completeChatViewProgress(state);
@ -634,7 +626,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
const last = next[next.length - 1]; const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") }); next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
return { turns: next, entityProgressMap: completedState.entityProgressMap }; return { turns: next, progressIndex: completedState.progressIndex };
} }
case "userPrompt": case "userPrompt":
case "UserPrompt": case "UserPrompt":
@ -642,14 +634,14 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
turns: appendUserPrompt( turns: appendUserPrompt(
turns, turns,
String(raw.text ?? raw.prompt ?? ""), String(raw.text ?? raw.prompt ?? ""),
attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)), attachmentLabels(raw.attachments),
), ),
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
default: default:
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
entityProgressMap: state.entityProgressMap, progressIndex: state.progressIndex,
}; };
} }
} }
@ -664,6 +656,9 @@ export function CustomAgentChatView({
nodeId, nodeId,
sessionId, sessionId,
conversationId, conversationId,
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
onSessionId, onSessionId,
onConversationId, onConversationId,
}: CustomAgentChatViewProps) { }: CustomAgentChatViewProps) {
@ -672,7 +667,7 @@ export function CustomAgentChatView({
const [turns, setTurns] = useState<ChatTurn[]>( const [turns, setTurns] = useState<ChatTurn[]>(
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
); );
const [entityProgressMap, setEntityProgressMap] = useState<Map<string, ReplyProgress>>(new Map()); const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(new Map());
const [currentSession, setCurrentSession] = useState(sessionId); const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@ -688,8 +683,9 @@ export function CustomAgentChatView({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns); const turnsRef = useRef(turns);
const entityProgressMapRef = useRef(entityProgressMap); const progressIndexRef = useRef(progressIndex);
const sessionRef = useRef<string | null>(sessionId); const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession; sessionRef.current = currentSession;
const scrollbackChunkCountRef = useRef( const scrollbackChunkCountRef = useRef(
@ -706,6 +702,9 @@ export function CustomAgentChatView({
const lastPersistedDraftRef = useRef(""); const lastPersistedDraftRef = useRef("");
const draftRef = useRef(""); const draftRef = useRef("");
draftRef.current = draft; draftRef.current = draft;
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
const imageTokenSeqRef = useRef(0);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0); const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined); const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
@ -714,11 +713,68 @@ export function CustomAgentChatView({
const onConversationIdRef = useRef(onConversationId); const onConversationIdRef = useRef(onConversationId);
onConversationIdRef.current = onConversationId; onConversationIdRef.current = onConversationId;
const clampComposerHeight = useCallback(
(heightPx: number): number => {
const visibleHeight =
chatRootRef.current?.getBoundingClientRect().height ??
composerRef.current?.closest<HTMLElement>("[data-custom-chat-shell]")
?.getBoundingClientRect().height ??
0;
const maxHeight =
visibleHeight > 0
? Math.max(composerDefaultHeightPx, Math.floor(visibleHeight * 0.5))
: Number.POSITIVE_INFINITY;
return Math.round(
Math.min(Math.max(heightPx, composerDefaultHeightPx), maxHeight),
);
},
[composerDefaultHeightPx],
);
const commitComposerHeight = useCallback(
(heightPx: number): void => {
onComposerHeightPxChange?.(clampComposerHeight(heightPx));
},
[clampComposerHeight, onComposerHeightPxChange],
);
const startComposerResize = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const startY = event.clientY;
const startHeight = composerHeightPx;
const onMove = (moveEvent: MouseEvent) => {
commitComposerHeight(startHeight + startY - moveEvent.clientY);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[commitComposerHeight, composerHeightPx],
);
const handleComposerResizeKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault();
commitComposerHeight(composerHeightPx + COMPOSER_RESIZE_STEP);
} else if (event.key === "ArrowDown") {
event.preventDefault();
commitComposerHeight(composerHeightPx - COMPOSER_RESIZE_STEP);
}
},
[commitComposerHeight, composerHeightPx],
);
const applyChatViewState = useCallback((next: ChatViewState) => { const applyChatViewState = useCallback((next: ChatViewState) => {
turnsRef.current = next.turns; turnsRef.current = next.turns;
entityProgressMapRef.current = next.entityProgressMap; progressIndexRef.current = next.progressIndex;
setTurns(next.turns); setTurns(next.turns);
setEntityProgressMap(next.entityProgressMap); setProgressIndex(next.progressIndex);
}, []); }, []);
const updateChatViewState = useCallback( const updateChatViewState = useCallback(
@ -726,7 +782,7 @@ export function CustomAgentChatView({
applyChatViewState( applyChatViewState(
updater({ updater({
turns: turnsRef.current, turns: turnsRef.current,
entityProgressMap: entityProgressMapRef.current, progressIndex: progressIndexRef.current,
}), }),
); );
}, },
@ -746,16 +802,7 @@ export function CustomAgentChatView({
() => (layoutId ? { projectId, layoutId, nodeId } : null), () => (layoutId ? { projectId, layoutId, nodeId } : null),
[layoutId, nodeId, projectId], [layoutId, nodeId, projectId],
); );
const displayTurns = useMemo<ChatTurn[]>( const displayTurns = turns;
() => [
...turns,
...Array.from(entityProgressMap.values()).map((progress) => ({
role: "progress" as const,
progress,
})),
],
[entityProgressMap, turns],
);
const clearPersistedDraft = useCallback(async () => { const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return; if (!draftScope) return;
@ -827,10 +874,10 @@ export function CustomAgentChatView({
if (!currentSession) return; if (!currentSession) return;
chatSessionViewCache.set(currentSession, { chatSessionViewCache.set(currentSession, {
turns, turns,
entityProgressMap, progressIndex,
chunkCount: scrollbackChunkCountRef.current, chunkCount: scrollbackChunkCountRef.current,
}); });
}, [currentSession, entityProgressMap, turns]); }, [currentSession, progressIndex, turns]);
useEffect(() => { useEffect(() => {
draftSaveSeqRef.current += 1; draftSaveSeqRef.current += 1;
@ -1128,7 +1175,7 @@ export function CustomAgentChatView({
scrollbackChunkCountRef.current = cached.chunkCount; scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({ applyChatViewState({
turns: cached.turns, turns: cached.turns,
entityProgressMap: cached.entityProgressMap ?? new Map(), progressIndex: cached.progressIndex ?? new Map(),
}); });
} }
try { try {
@ -1272,7 +1319,7 @@ export function CustomAgentChatView({
applyChatViewState({ applyChatViewState({
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }], turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }],
entityProgressMap: new Map(), progressIndex: new Map(),
}); });
setProfileDialog(null); setProfileDialog(null);
} catch (e) { } catch (e) {
@ -1307,7 +1354,7 @@ export function CustomAgentChatView({
const helpText = formatSlashCommandHelp(result.effect.commands); const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "agent", text: helpText }], turns: [...prev.turns, { role: "agent", text: helpText }],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1330,7 +1377,7 @@ export function CustomAgentChatView({
...prev.turns, ...prev.turns,
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
], ],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1339,7 +1386,7 @@ export function CustomAgentChatView({
setError(message); setError(message);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: message }], turns: [...prev.turns, { role: "error", text: message }],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
} }
} }
@ -1358,6 +1405,15 @@ export function CustomAgentChatView({
} }
} }
function removeAttachment(item: AttachmentDraft) {
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
);
if (item.token) {
editDraft(removeAttachmentToken(draftRef.current, item.token));
}
}
async function pasteClipboardFiles( async function pasteClipboardFiles(
event: ClipboardEvent<HTMLTextAreaElement>, event: ClipboardEvent<HTMLTextAreaElement>,
) { ) {
@ -1375,26 +1431,42 @@ export function CustomAgentChatView({
const nextAttachments = await Promise.all( const nextAttachments = await Promise.all(
files.map(clipboardFileToAttachment), files.map(clipboardFileToAttachment),
); );
setAttachments((prev) => { const known = new Set(
const known = new Set( attachmentsRef.current
prev .map((attachment) => attachment.dedupeKey)
.map((attachment) => attachment.dedupeKey) .filter((key): key is string => Boolean(key)),
.filter((key): key is string => Boolean(key)), );
); const uniqueAttachments = nextAttachments
const unique = nextAttachments.filter((attachment) => { .filter((attachment) => {
if (!attachment.dedupeKey) return true; if (!attachment.dedupeKey) return true;
if (known.has(attachment.dedupeKey)) return false; if (known.has(attachment.dedupeKey)) return false;
known.add(attachment.dedupeKey); known.add(attachment.dedupeKey);
return true; return true;
})
.map((attachment) => {
if (!attachment.previewUrl) return attachment;
const token = `[Image ${++imageTokenSeqRef.current}]`;
return { ...attachment, token };
}); });
return unique.length > 0 ? [...prev, ...unique] : prev; if (uniqueAttachments.length > 0) {
}); setAttachments((prev) => [...prev, ...uniqueAttachments]);
if (filesFromEvent.length > 0 && pastedText) { }
const imageTokens = uniqueAttachments
.map((attachment) => attachment.token)
.filter((token): token is string => Boolean(token));
const insertionParts = [
...(pastedText ? [pastedText] : []),
...(imageTokens.length > 0 ? [imageTokens.join(" ")] : []),
];
if (insertionParts.length > 0) {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setDraft((prev) => setDraft((prev) =>
prev.slice(0, selectionStart) + insertTextAtSelection(
pastedText + prev,
prev.slice(selectionEnd), insertionParts.join(" "),
selectionStart,
selectionEnd,
),
); );
} }
} catch (e) { } catch (e) {
@ -1412,7 +1484,7 @@ export function CustomAgentChatView({
} }
const outgoingAttachments = attachments; const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentRefs = attachmentRefsFromDrafts(outgoingAttachments); const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe"; const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`; const interactionId = `${agentId}:${++interactionSeqRef.current}`;
activeInteractionIdRef.current = interactionId; activeInteractionIdRef.current = interactionId;
@ -1424,6 +1496,7 @@ export function CustomAgentChatView({
}; };
setDraft(""); setDraft("");
setAttachments([]); setAttachments([]);
imageTokenSeqRef.current = 0;
void clearPersistedDraft(); void clearPersistedDraft();
setBusy(true); setBusy(true);
setError(null); setError(null);
@ -1433,10 +1506,10 @@ export function CustomAgentChatView({
{ {
role: "user", role: "user",
text: displayText, text: displayText,
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}), ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
}, },
], ],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
const sendTurn = (sid: string) => const sendTurn = (sid: string) =>
attachmentInputs.length > 0 attachmentInputs.length > 0
@ -1469,7 +1542,7 @@ export function CustomAgentChatView({
setError(describe(recoveryError)); setError(describe(recoveryError));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }], turns: [...prev.turns, { role: "error", text: describe(recoveryError) }],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
return; return;
} }
@ -1479,7 +1552,7 @@ export function CustomAgentChatView({
setError(describe(e)); setError(describe(e));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(e) }], turns: [...prev.turns, { role: "error", text: describe(e) }],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
} }
} }
@ -1512,7 +1585,7 @@ export function CustomAgentChatView({
: "Agent arrêté.", : "Agent arrêté.",
}, },
], ],
entityProgressMap: completed.entityProgressMap, progressIndex: completed.progressIndex,
}; };
}); });
} catch (e) { } catch (e) {
@ -1532,7 +1605,7 @@ export function CustomAgentChatView({
...completed.turns, ...completed.turns,
{ role: "tool", label: "Session déjà arrêtée." }, { role: "tool", label: "Session déjà arrêtée." },
], ],
entityProgressMap: completed.entityProgressMap, progressIndex: completed.progressIndex,
}; };
}); });
return; return;
@ -1543,7 +1616,7 @@ export function CustomAgentChatView({
...prev.turns, ...prev.turns,
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, { role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
], ],
entityProgressMap: prev.entityProgressMap, progressIndex: prev.progressIndex,
})); }));
} finally { } finally {
setStopping(false); setStopping(false);
@ -1552,6 +1625,7 @@ export function CustomAgentChatView({
return ( return (
<div <div
ref={chatRootRef}
data-testid="custom-agent-chat-view" data-testid="custom-agent-chat-view"
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content" className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
> >
@ -1721,11 +1795,7 @@ export function CustomAgentChatView({
type="button" type="button"
aria-label={`retirer ${item.label}`} aria-label={`retirer ${item.label}`}
className="shrink-0 text-content" className="shrink-0 text-content"
onClick={() => onClick={() => removeAttachment(item)}
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
)
}
> >
Retirer Retirer
</button> </button>
@ -1772,55 +1842,73 @@ export function CustomAgentChatView({
</div> </div>
)} )}
<div className="flex min-w-0 items-end gap-2"> <div className="flex min-w-0 items-end gap-2">
<textarea <div className="relative flex min-w-0 flex-1">
ref={composerRef} <textarea
aria-label={`message CLI custom ${nodeId}`} ref={composerRef}
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined} aria-label={`message CLI custom ${nodeId}`}
aria-activedescendant={ aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined aria-activedescendant={
} slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
value={draft}
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
onChange={(e) => editDraft(e.target.value)}
onPaste={(e) => void pasteClipboardFiles(e)}
onKeyDown={(e) => {
if (slashMenuOpen && slashCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashActiveIndex(
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuOpen(false);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const command = slashCommands[slashActiveIndex];
if (command) selectSlashCommand(command);
return;
}
} }
if (e.key === "Enter" && !e.shiftKey) { className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 pr-7 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
value={draft}
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)}
onPaste={(e) => void pasteClipboardFiles(e)}
onKeyDown={(e) => {
if (slashMenuOpen && slashCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashActiveIndex(
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuOpen(false);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const command = slashCommands[slashActiveIndex];
if (command) selectSlashCommand(command);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<button
type="button"
aria-label={`redimensionner le composer ${nodeId}`}
title="Redimensionner le composer"
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
style={{ cursor: "ns-resize" }}
onMouseDown={startComposerResize}
onDoubleClick={(e) => {
e.preventDefault(); e.preventDefault();
void send(); commitComposerHeight(composerDefaultHeightPx);
} }}
}} onKeyDown={handleComposerResizeKeyDown}
/> >
↕
</button>
</div>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -1888,6 +1976,9 @@ function ChatBubble({
return ( return (
<div className="flex min-w-0 justify-center"> <div className="flex min-w-0 justify-center">
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"> <div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "error" && (
<div className="mb-1 font-semibold">Échec</div>
)}
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""} {turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span> <span className="whitespace-pre-wrap break-words">{turn.text}</span>
</div> </div>
@ -1908,8 +1999,8 @@ function ChatBubble({
{user && turn.attachments && turn.attachments.length > 0 && ( {user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted"> <div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => ( {turn.attachments.map((attachment) => (
<p key={`${attachment.reference}-${attachment.filename}`} className="truncate"> <p key={attachment} className="truncate">
{attachment.reference} {attachment.filename} Fichier: {attachment}
</p> </p>
))} ))}
</div> </div>
@ -1942,11 +2033,11 @@ function progressSourceLabel(progress: ReplyProgress): string {
function progressStageLabel(stage: ReplyProgressStage): string { function progressStageLabel(stage: ReplyProgressStage): string {
switch (stage) { switch (stage) {
case "started": case "started":
return "started"; return "En cours";
case "delta": case "delta":
return "running"; return "En cours";
case "completed": case "completed":
return "done"; return "Terminé";
case "info": case "info":
return "info"; return "info";
} }

View File

@ -180,35 +180,47 @@ 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("resizes the custom CLI chat locally per cell and can reset to the default size", async () => { it("persists custom CLI composer height per layout cell", async () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({
x: 0,
y: 0,
width: 600,
height: 400,
top: 0,
right: 600,
bottom: 400,
left: 0,
toJSON: () => {},
} as DOMRect);
const gateways = await seeded(structuredProfile); const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway; const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1"); const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id; const leafId = leaves(tree)[0].id;
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
renderGrid(gateways); try {
renderGrid(gateways);
const shell = await screen.findByTestId("custom-agent-chat-shell"); const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
expect(shell.style.width).toBe("100%"); const handle = screen.getByRole("button", {
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull(); name: `redimensionner le composer ${leafId}`,
});
expect(composer.style.height).toBe("64px");
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBeNull();
fireEvent.mouseDown(screen.getByRole("button", { name: `resize custom chat ${leafId}` }), { fireEvent.mouseDown(handle, { clientY: 200 });
clientX: 0, fireEvent.mouseMove(window, { clientY: 100 });
clientY: 0, fireEvent.mouseUp(window);
});
fireEvent.mouseMove(window, { clientX: 640, clientY: 420 });
fireEvent.mouseUp(window);
await waitFor(() => expect(shell.style.width).toBe("640px")); await waitFor(() => expect(composer.style.height).toBe("164px"));
expect(shell.style.height).toBe("420px"); expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
expect(JSON.parse(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`) ?? "{}")) .toBe("164");
.toEqual({ width: 640, height: 420 }); } finally {
rectSpy.mockRestore();
fireEvent.click(screen.getByRole("button", { name: `reset custom chat size ${leafId}` })); }
await waitFor(() => expect(shell.style.width).toBe("100%"));
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull();
}); });
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => { it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {

View File

@ -16,7 +16,7 @@
* {@link normalizeWeights} function, kept out of the render for testability. * {@link normalizeWeights} function, kept out of the render for testability.
*/ */
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Agent, AgentProfile } from "@/domain"; import type { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain"; import type { LayoutNode } from "@/domain";
@ -407,47 +407,16 @@ const CELL_Z = {
turnActions: 6, turnActions: 6,
} as const; } as const;
const CUSTOM_CHAT_MIN_WIDTH = 360; const CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT = 64;
const CUSTOM_CHAT_MIN_HEIGHT = 280;
const CUSTOM_CHAT_MAX_WIDTH = 1200;
const CUSTOM_CHAT_MAX_HEIGHT = 900;
interface CustomChatSize { function readCustomChatComposerHeight(storageKey: string): number {
width: number; if (typeof window === "undefined") return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
height: number;
}
function clampCustomChatSize(size: CustomChatSize): CustomChatSize {
const viewportWidth =
typeof window === "undefined" ? CUSTOM_CHAT_MAX_WIDTH : window.innerWidth - 24;
const viewportHeight =
typeof window === "undefined" ? CUSTOM_CHAT_MAX_HEIGHT : window.innerHeight - 24;
return {
width: Math.round(
Math.min(
Math.max(size.width, CUSTOM_CHAT_MIN_WIDTH),
Math.max(CUSTOM_CHAT_MIN_WIDTH, Math.min(CUSTOM_CHAT_MAX_WIDTH, viewportWidth)),
),
),
height: Math.round(
Math.min(
Math.max(size.height, CUSTOM_CHAT_MIN_HEIGHT),
Math.max(CUSTOM_CHAT_MIN_HEIGHT, Math.min(CUSTOM_CHAT_MAX_HEIGHT, viewportHeight)),
),
),
};
}
function readCustomChatSize(storageKey: string): CustomChatSize | null {
if (typeof window === "undefined") return null;
try { try {
const raw = window.localStorage.getItem(storageKey); const parsed = Number(window.localStorage.getItem(storageKey));
if (!raw) return null; if (!Number.isFinite(parsed)) return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
const parsed = JSON.parse(raw) as Partial<CustomChatSize>; return Math.max(CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT, Math.round(parsed));
if (typeof parsed.width !== "number" || typeof parsed.height !== "number") return null;
return clampCustomChatSize({ width: parsed.width, height: parsed.height });
} catch { } catch {
return null; return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
} }
} }
@ -570,53 +539,25 @@ function LeafView({
}, },
[cellModeStorageKey], [cellModeStorageKey],
); );
const customChatSizeStorageKey = `idea.custom-chat-size.${projectId}.${id}`; const composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
const [customChatSize, setCustomChatSizeState] = useState<CustomChatSize | null>(() => const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
readCustomChatSize(customChatSizeStorageKey), readCustomChatComposerHeight(composerHeightStorageKey),
); );
const setCustomChatSize = useCallback( const setCustomChatComposerHeight = useCallback(
(size: CustomChatSize | null) => { (height: number) => {
setCustomChatSizeState(size); const nextHeight = Math.max(
CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT,
Math.round(height),
);
setCustomChatComposerHeightState(nextHeight);
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
if (size) { window.localStorage.setItem(composerHeightStorageKey, String(nextHeight));
window.localStorage.setItem(customChatSizeStorageKey, JSON.stringify(size));
} else {
window.localStorage.removeItem(customChatSizeStorageKey);
}
} catch { } catch {
/* local preference only */ /* local preference only */
} }
}, },
[customChatSizeStorageKey], [composerHeightStorageKey],
);
const startCustomChatResize = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
const container = event.currentTarget.closest<HTMLElement>("[data-custom-chat-shell]");
if (!container) return;
event.preventDefault();
event.stopPropagation();
const rect = container.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = customChatSize?.width ?? rect.width;
const startHeight = customChatSize?.height ?? rect.height;
const onMove = (moveEvent: MouseEvent) => {
setCustomChatSize(
clampCustomChatSize({
width: startWidth + moveEvent.clientX - startX,
height: startHeight + moveEvent.clientY - startY,
}),
);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[customChatSize, setCustomChatSize],
); );
const [pendingModeSwitch, setPendingModeSwitch] = const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null); useState<PendingModeSwitch | null>(null);
@ -1192,7 +1133,7 @@ function LeafView({
minWidth: 0, minWidth: 0,
border: "1px solid #2a2a2a", border: "1px solid #2a2a2a",
boxSizing: "border-box", boxSizing: "border-box",
overflow: customChatSize ? "visible" : "hidden", overflow: "hidden",
}} }}
> >
<div <div
@ -1556,85 +1497,23 @@ function LeafView({
onRefresh={() => void refreshStoppedAgentState()} onRefresh={() => void refreshStoppedAgentState()}
/> />
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<div <CustomAgentChatView
data-custom-chat-shell key={`${id}-${agentId}-custom`}
data-testid="custom-agent-chat-shell" projectId={projectId}
style={{ agentId={agentId}
position: customChatSize ? "absolute" : "relative", agentName={customCliAgent.name}
top: 0, profile={customCliProfile}
left: 0, cwd={cwd}
zIndex: customChatSize ? 2 : 0, layoutId={layoutId}
width: customChatSize ? customChatSize.width : "100%", nodeId={id}
height: customChatSize ? customChatSize.height : "100%", sessionId={session}
minWidth: CUSTOM_CHAT_MIN_WIDTH, conversationId={conversationId}
minHeight: CUSTOM_CHAT_MIN_HEIGHT, composerHeightPx={customChatComposerHeight}
maxWidth: "calc(100vw - 24px)", composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
maxHeight: "calc(100vh - 24px)", onComposerHeightPxChange={setCustomChatComposerHeight}
boxShadow: customChatSize ? "0 18px 48px rgba(0, 0, 0, 0.42)" : undefined, onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
}} onConversationId={(cid) => void vm.setCellConversation(id, cid)}
> />
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
{customChatSize && (
<button
type="button"
aria-label={`reset custom chat size ${id}`}
title="Reset chat size"
onClick={() => setCustomChatSize(null)}
style={{
position: "absolute",
right: 30,
bottom: 6,
zIndex: 30,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
Reset
</button>
)}
<button
type="button"
aria-label={`resize custom chat ${id}`}
title="Resize chat"
onMouseDown={startCustomChatResize}
style={{
position: "absolute",
right: 6,
bottom: 6,
zIndex: 30,
width: 16,
height: 16,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-muted, #9aa7b4)",
cursor: "nwse-resize",
fontSize: 11,
lineHeight: "12px",
padding: 0,
}}
>
↘
</button>
</div>
) : ( ) : (
<TerminalView <TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`} key={`${id}-${agentId ?? "plain"}-${attachGen}`}