diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index b6c70137..55f793c4 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1681,6 +1681,7 @@ export type ReplyProgressStage = "started" | "delta" | "completed" | "info"; export interface ReplyProgress { progressId?: string; + occurredAtMs?: number; source: ReplyProgressSource; kind: ReplyProgressKind; stage: ReplyProgressStage; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 5461912b..d16402e2 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -78,7 +78,7 @@ describe("CustomAgentChatView", () => { __resetProjectCliInteractionActivityForTests(); }); - it("repaints compact attachment refs from legacy user prompt scrollback", async () => { + it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ @@ -119,68 +119,91 @@ describe("CustomAgentChatView", () => { ); 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 = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, - 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", - }, - ], - }, - ], + scrollback: [], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; - render( - null) }, - } as unknown as Gateways} - > - - , - ); + function Harness() { + const [height, setHeight] = useState(64); + return ( + null) }, + } as unknown as Gateways} + > + + + ); + } - expect(await screen.findByText("compare these")).toBeTruthy(); - expect(screen.getByText("[image 1] screenshot.png")).toBeTruthy(); - expect(screen.getByText("[file 1] error.log")).toBeTruthy(); + try { + render(); + 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 () => { @@ -895,7 +918,7 @@ describe("CustomAgentChatView", () => { }); 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(); act(() => { @@ -909,8 +932,8 @@ describe("CustomAgentChatView", () => { }); await screen.findByText("done"); - expect(screen.getAllByText("Worker ← QA")).toHaveLength(2); - expect(screen.getAllByText("validate")).toHaveLength(2); + expect(screen.getAllByText("Worker ← QA")).toHaveLength(1); + expect(screen.getAllByText("validate")).toHaveLength(1); expect(screen.queryByText("Appel inter-agent")).toBeNull(); expect(screen.queryByText("reply delivered")).toBeNull(); expect(screen.queryByText("Tool")).toBeNull(); @@ -1072,7 +1095,7 @@ describe("CustomAgentChatView", () => { expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.queryByText("Downloading 10%")).toBeNull(); 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 () => { @@ -1149,6 +1172,97 @@ describe("CustomAgentChatView", () => { 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((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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 () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { @@ -1225,7 +1339,7 @@ describe("CustomAgentChatView", () => { expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.queryByText("Searching...")).toBeNull(); expect(screen.getByText("Found results")).toBeTruthy(); - expect(screen.getByText("Tool").parentElement?.textContent).toContain("done"); + expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé"); act(() => { emitChunk?.({ kind: "final", content: "done" }); @@ -1306,7 +1420,7 @@ describe("CustomAgentChatView", () => { }); 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(() => { emitChunk?.({ kind: "final", content: "done" }); @@ -1317,93 +1431,7 @@ describe("CustomAgentChatView", () => { expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(), ); expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); - expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); - }); - - 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((resolve) => { - emitChunk = (chunk: unknown) => { - onChunk(chunk); - if ((chunk as { kind?: string }).kind === "final") resolve(); - }; - }), - ), - cancelAgentChat: vi.fn(async () => {}), - closeAgentChat: vi.fn(async () => {}), - }; - - render( - null) }, - } as unknown as Gateways} - > - - , - ); - - 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"); + expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé"); }); it("does not append late progress chunks after the final answer", async () => { @@ -2123,7 +2151,8 @@ describe("CustomAgentChatView", () => { Object.defineProperty(file, "arrayBuffer", { 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: { items: [ { @@ -2138,12 +2167,14 @@ describe("CustomAgentChatView", () => { expect(await screen.findByText("Fichier joint: 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" })); await waitFor(() => expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(), ); + expect(composer.value).toBe(""); expect( (screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement) .disabled, @@ -2223,7 +2254,7 @@ describe("CustomAgentChatView", () => { await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", - "", + "[Image 1]", expect.any(Function), { attachments: [ @@ -2389,7 +2420,7 @@ describe("CustomAgentChatView", () => { await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", - "", + "[Image 1]", expect.any(Function), { attachments: [ @@ -2402,8 +2433,8 @@ describe("CustomAgentChatView", () => { ], }, ); - expect(screen.getByText("Pièce jointe")).toBeTruthy(); - expect(screen.getByText("[image 1] paste-image.png")).toBeTruthy(); + expect(screen.getByText("[Image 1]")).toBeTruthy(); + expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy(); }); 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("[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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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 () => { @@ -2826,13 +2941,13 @@ describe("CustomAgentChatView", () => { }); await screen.findByText("Fichier joint: not-persisted.png"); await waitFor(() => - expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"), + expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file[Image 1]"), ); first.unmount(); renderView(); - await screen.findByDisplayValue("draft with file"); + await screen.findByDisplayValue("draft with file[Image 1]"); expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull(); }); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index a789d22f..e8ac72af 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -13,11 +13,12 @@ import { useRef, useState, type ClipboardEvent, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, } from "react"; import type { AgentProfile, - ChatPromptAttachment, GatewayError, ReplyChunk, ReplyProgress, @@ -46,12 +47,15 @@ export interface CustomAgentChatViewProps { nodeId: string; sessionId: string | null; conversationId: string | null; + composerHeightPx?: number; + composerDefaultHeightPx?: number; + onComposerHeightPxChange?: (heightPx: number) => void; onSessionId: (sessionId: string | null) => void; onConversationId: (conversationId: string | null) => void; } type ChatTurn = - | { role: "user"; text: string; attachments?: ChatPromptAttachment[] } + | { role: "user"; text: string; attachments?: string[] } | { role: "agent"; text: string; pending?: boolean } | { role: "tool"; label: string } | { role: "progress"; progress: ReplyProgress; settled?: boolean } @@ -61,12 +65,14 @@ type ChatTurn = interface CachedChatView { turns: ChatTurn[]; - entityProgressMap: Map; + progressIndex: Map; chunkCount: number; } const chatSessionViewCache = new Map(); 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() { chatSessionViewCache.clear(); @@ -78,6 +84,12 @@ interface AttachmentDraft { input: ChatAttachmentInput; previewUrl?: string; dedupeKey?: string; + token?: string; +} + +interface ProgressIndexEntry { + firstOccurredAtMs: number; + sequence: number; } 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; - 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( turns: ChatTurn[], text: string, - attachments: ChatPromptAttachment[] = [], + attachments: string[] = [], ): ChatTurn[] { const last = turns[turns.length - 1]; if ( @@ -268,6 +228,10 @@ function normalizeProgress(raw: unknown): ReplyProgress | null { if (!label) return null; return { progressId: optionalString(record.progressId), + occurredAtMs: + typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs) + ? record.occurredAtMs + : undefined, source: isReplyProgressSource(record.source) ? record.source : "providerNative", kind: isReplyProgressKind(record.kind) ? record.kind : "other", stage: isReplyProgressStage(record.stage) ? record.stage : "info", @@ -281,7 +245,6 @@ function normalizeProgress(raw: unknown): ReplyProgress | null { function progressKey(progress: ReplyProgress): string { return [ - progress.progressId ?? "", progress.source, progress.kind, progress.stage, @@ -291,6 +254,14 @@ function progressKey(progress: ReplyProgress): string { ].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 { const name = progress.toolName ?? progress.label; const normalized = name.trim().toLowerCase(); @@ -303,7 +274,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean { } function progressEntityKey(progress: ReplyProgress): string | null { - if (progress.progressId) return `id:${progress.progressId}`; const name = normalizedProgressToolName(progress); if (!name) return null; if (isDelegationProgress(progress)) return `delegation:${name}`; @@ -312,7 +282,7 @@ function progressEntityKey(progress: ReplyProgress): string | null { } 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 === "completed") return "completed"; return "info"; @@ -326,54 +296,61 @@ function progressDisplayKey(progress: ReplyProgress): string { interface ChatViewState { turns: ChatTurn[]; - entityProgressMap: Map; + progressIndex: Map; } function emptyChatViewState(): ChatViewState { - return { turns: [], entityProgressMap: new Map() }; + return { turns: [], progressIndex: new Map() }; } function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { - const entity = progressEntityKey(progress); - if (entity) { - const bucket = progressStageBucket(progress); - const key = `${entity}\u0000${bucket}`; - if (bucket === "started") { - const nextMap = new Map(state.entityProgressMap); - nextMap.set(key, progress); - return { turns: state.turns, entityProgressMap: nextMap }; - } - if (bucket === "completed") { - const runningKey = `${entity}\u0000started`; - const running = state.entityProgressMap.get(runningKey); - const nextMap = new Map(state.entityProgressMap); - nextMap.delete(runningKey); - if ( - !running && - state.turns.some( - (turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, - ) - ) { - return { turns: state.turns, entityProgressMap: nextMap }; - } - const completed = running ? mergeCompletedProgress(running, progress) : progress; - return { - turns: [...state.turns, { role: "progress", progress: completed }], - entityProgressMap: nextMap, - }; - } + const key = progressUpsertKey(progress); + const existingIndex = state.turns.findIndex( + (turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key, + ); + const existingProgress = + existingIndex >= 0 && state.turns[existingIndex].role === "progress" + ? state.turns[existingIndex].progress + : null; + const merged = + existingProgress && progress.stage === "completed" + ? mergeCompletedProgress(existingProgress, progress) + : progress; + const nextIndex = new Map(state.progressIndex); + const existingEntry = nextIndex.get(key); + const firstOccurredAtMs = + existingEntry?.firstOccurredAtMs ?? + progress.occurredAtMs ?? + Date.now(); + const sequence = existingEntry?.sequence ?? nextIndex.size; + nextIndex.set(key, { firstOccurredAtMs, sequence }); + + if (existingIndex >= 0) { + const nextTurns = [...state.turns]; + nextTurns[existingIndex] = { role: "progress", progress: merged }; + return { turns: nextTurns, progressIndex: nextIndex }; } - const key = progressDisplayKey(progress); - if ( - state.turns.some( - (turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, - ) - ) { - return state; + + const insertAt = state.turns.findIndex((turn) => { + if (turn.role !== "progress") return false; + const candidate = state.progressIndex.get(progressUpsertKey(turn.progress)); + if (!candidate) return false; + if (candidate.firstOccurredAtMs !== firstOccurredAtMs) { + 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 { - turns: [...state.turns, { role: "progress", progress }], - entityProgressMap: state.entityProgressMap, + turns: [ + ...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 { - const activeTurns = Array.from(state.entityProgressMap.values()).map((progress) => ({ - role: "progress" as const, - progress, - })); return { - turns: completeRunningProgress([...state.turns, ...activeTurns]), - entityProgressMap: new Map(), + turns: completeRunningProgress(state.turns), + progressIndex: state.progressIndex, }; } @@ -445,7 +418,6 @@ function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { } function turnsIndicateBusy(state: ChatViewState): boolean { - if (state.entityProgressMap.size > 0) return true; for (let index = state.turns.length - 1; index >= 0; index -= 1) { const turn = state.turns[index]; if (turn.role === "final" || turn.role === "error") return false; @@ -461,6 +433,26 @@ function turnsIndicateBusy(state: ChatViewState): boolean { 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 { if (mime === "image/png") return "png"; if (mime === "image/jpeg") return "jpg"; @@ -584,7 +576,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { if (!isReplyRecord(raw)) { return { turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; } const last = turns[turns.length - 1]; @@ -602,19 +594,19 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { case "textDelta": return { turns: appendAgentDelta(turns, String(raw.text ?? "")), - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; case "toolActivity": return { turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }], - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; case "progress": { const progress = normalizeProgress(raw.progress); if (!progress) { return { turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; } return appendProgress(state, progress); @@ -626,7 +618,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { const last = next[next.length - 1]; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; next.push({ role: "final", text: content }); - return { turns: next, entityProgressMap: completedState.entityProgressMap }; + return { turns: next, progressIndex: completedState.progressIndex }; } case "error": { const completedState = completeChatViewProgress(state); @@ -634,7 +626,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { const last = next[next.length - 1]; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; 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": @@ -642,14 +634,14 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { turns: appendUserPrompt( turns, String(raw.text ?? raw.prompt ?? ""), - attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)), + attachmentLabels(raw.attachments), ), - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; default: return { turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], - entityProgressMap: state.entityProgressMap, + progressIndex: state.progressIndex, }; } } @@ -664,6 +656,9 @@ export function CustomAgentChatView({ nodeId, sessionId, conversationId, + composerHeightPx = FALLBACK_COMPOSER_HEIGHT, + composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT, + onComposerHeightPxChange, onSessionId, onConversationId, }: CustomAgentChatViewProps) { @@ -672,7 +667,7 @@ export function CustomAgentChatView({ const [turns, setTurns] = useState( () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), ); - const [entityProgressMap, setEntityProgressMap] = useState>(new Map()); + const [progressIndex, setProgressIndex] = useState>(new Map()); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); @@ -688,8 +683,9 @@ export function CustomAgentChatView({ const [error, setError] = useState(null); const scrollRef = useRef(null); const composerRef = useRef(null); + const chatRootRef = useRef(null); const turnsRef = useRef(turns); - const entityProgressMapRef = useRef(entityProgressMap); + const progressIndexRef = useRef(progressIndex); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; const scrollbackChunkCountRef = useRef( @@ -706,6 +702,9 @@ export function CustomAgentChatView({ const lastPersistedDraftRef = useRef(""); const draftRef = useRef(""); draftRef.current = draft; + const attachmentsRef = useRef([]); + attachmentsRef.current = attachments; + const imageTokenSeqRef = useRef(0); const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(undefined); @@ -714,11 +713,68 @@ export function CustomAgentChatView({ const onConversationIdRef = useRef(onConversationId); onConversationIdRef.current = onConversationId; + const clampComposerHeight = useCallback( + (heightPx: number): number => { + const visibleHeight = + chatRootRef.current?.getBoundingClientRect().height ?? + composerRef.current?.closest("[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) => { + 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) => { + 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) => { turnsRef.current = next.turns; - entityProgressMapRef.current = next.entityProgressMap; + progressIndexRef.current = next.progressIndex; setTurns(next.turns); - setEntityProgressMap(next.entityProgressMap); + setProgressIndex(next.progressIndex); }, []); const updateChatViewState = useCallback( @@ -726,7 +782,7 @@ export function CustomAgentChatView({ applyChatViewState( updater({ turns: turnsRef.current, - entityProgressMap: entityProgressMapRef.current, + progressIndex: progressIndexRef.current, }), ); }, @@ -746,16 +802,7 @@ export function CustomAgentChatView({ () => (layoutId ? { projectId, layoutId, nodeId } : null), [layoutId, nodeId, projectId], ); - const displayTurns = useMemo( - () => [ - ...turns, - ...Array.from(entityProgressMap.values()).map((progress) => ({ - role: "progress" as const, - progress, - })), - ], - [entityProgressMap, turns], - ); + const displayTurns = turns; const clearPersistedDraft = useCallback(async () => { if (!draftScope) return; @@ -827,10 +874,10 @@ export function CustomAgentChatView({ if (!currentSession) return; chatSessionViewCache.set(currentSession, { turns, - entityProgressMap, + progressIndex, chunkCount: scrollbackChunkCountRef.current, }); - }, [currentSession, entityProgressMap, turns]); + }, [currentSession, progressIndex, turns]); useEffect(() => { draftSaveSeqRef.current += 1; @@ -1128,7 +1175,7 @@ export function CustomAgentChatView({ scrollbackChunkCountRef.current = cached.chunkCount; applyChatViewState({ turns: cached.turns, - entityProgressMap: cached.entityProgressMap ?? new Map(), + progressIndex: cached.progressIndex ?? new Map(), }); } try { @@ -1272,7 +1319,7 @@ export function CustomAgentChatView({ applyChatViewState({ turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }], - entityProgressMap: new Map(), + progressIndex: new Map(), }); setProfileDialog(null); } catch (e) { @@ -1307,7 +1354,7 @@ export function CustomAgentChatView({ const helpText = formatSlashCommandHelp(result.effect.commands); updateChatViewState((prev) => ({ turns: [...prev.turns, { role: "agent", text: helpText }], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); return; } @@ -1330,7 +1377,7 @@ export function CustomAgentChatView({ ...prev.turns, { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, ], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); return; } @@ -1339,7 +1386,7 @@ export function CustomAgentChatView({ setError(message); updateChatViewState((prev) => ({ 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( event: ClipboardEvent, ) { @@ -1375,26 +1431,42 @@ export function CustomAgentChatView({ const nextAttachments = await Promise.all( files.map(clipboardFileToAttachment), ); - setAttachments((prev) => { - const known = new Set( - prev - .map((attachment) => attachment.dedupeKey) - .filter((key): key is string => Boolean(key)), - ); - const unique = nextAttachments.filter((attachment) => { + const known = new Set( + attachmentsRef.current + .map((attachment) => attachment.dedupeKey) + .filter((key): key is string => Boolean(key)), + ); + const uniqueAttachments = nextAttachments + .filter((attachment) => { if (!attachment.dedupeKey) return true; if (known.has(attachment.dedupeKey)) return false; known.add(attachment.dedupeKey); 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 (filesFromEvent.length > 0 && pastedText) { + if (uniqueAttachments.length > 0) { + setAttachments((prev) => [...prev, ...uniqueAttachments]); + } + 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; setDraft((prev) => - prev.slice(0, selectionStart) + - pastedText + - prev.slice(selectionEnd), + insertTextAtSelection( + prev, + insertionParts.join(" "), + selectionStart, + selectionEnd, + ), ); } } catch (e) { @@ -1412,7 +1484,7 @@ export function CustomAgentChatView({ } const outgoingAttachments = attachments; 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 interactionId = `${agentId}:${++interactionSeqRef.current}`; activeInteractionIdRef.current = interactionId; @@ -1424,6 +1496,7 @@ export function CustomAgentChatView({ }; setDraft(""); setAttachments([]); + imageTokenSeqRef.current = 0; void clearPersistedDraft(); setBusy(true); setError(null); @@ -1433,10 +1506,10 @@ export function CustomAgentChatView({ { role: "user", text: displayText, - ...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}), + ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); const sendTurn = (sid: string) => attachmentInputs.length > 0 @@ -1469,7 +1542,7 @@ export function CustomAgentChatView({ setError(describe(recoveryError)); updateChatViewState((prev) => ({ turns: [...prev.turns, { role: "error", text: describe(recoveryError) }], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); return; } @@ -1479,7 +1552,7 @@ export function CustomAgentChatView({ setError(describe(e)); updateChatViewState((prev) => ({ turns: [...prev.turns, { role: "error", text: describe(e) }], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); } } @@ -1512,7 +1585,7 @@ export function CustomAgentChatView({ : "Agent arrêté.", }, ], - entityProgressMap: completed.entityProgressMap, + progressIndex: completed.progressIndex, }; }); } catch (e) { @@ -1532,7 +1605,7 @@ export function CustomAgentChatView({ ...completed.turns, { role: "tool", label: "Session déjà arrêtée." }, ], - entityProgressMap: completed.entityProgressMap, + progressIndex: completed.progressIndex, }; }); return; @@ -1543,7 +1616,7 @@ export function CustomAgentChatView({ ...prev.turns, { role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, ], - entityProgressMap: prev.entityProgressMap, + progressIndex: prev.progressIndex, })); } finally { setStopping(false); @@ -1552,6 +1625,7 @@ export function CustomAgentChatView({ return (
@@ -1721,11 +1795,7 @@ export function CustomAgentChatView({ type="button" aria-label={`retirer ${item.label}`} className="shrink-0 text-content" - onClick={() => - setAttachments((prev) => - prev.filter((candidate) => candidate.id !== item.id), - ) - } + onClick={() => removeAttachment(item)} > Retirer @@ -1772,55 +1842,73 @@ export function CustomAgentChatView({
)}
-