diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 0fef93de..af38c524 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -23,6 +23,7 @@ import type { ChatDraftScope, Gateways } from "@/ports"; import { CustomAgentChatView, __clearCustomAgentChatViewCacheForTests, + customAgentChatQueueCount, } from "./CustomAgentChatView"; const profile: AgentProfile = { @@ -1366,7 +1367,7 @@ describe("CustomAgentChatView", () => { ).toBeNull(); }); - it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => { + it("keeps the composer active after a busy reattach replay settles (#190/#352)", async () => { let scrollback: ReplyChunk[] = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, @@ -1444,7 +1445,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Partial answer while away")).toBeTruthy(); expect(screen.queryByText(/Reprise effectuée:/)).toBeNull(); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); - expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(true); + expect(screen.getByLabelText(/Message CLI personnalisée/).hasAttribute("disabled")).toBe(false); }); it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => { @@ -1854,7 +1855,7 @@ describe("CustomAgentChatView", () => { }, ); - it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => { + it("keeps the composer active from canonical work-state busy and exposes queueing", async () => { const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); workState._setProjectWorkState("project-1", { @@ -1909,7 +1910,8 @@ describe("CustomAgentChatView", () => { ); const composer = await screen.findByLabelText(/Message CLI personnalisée/); - await waitFor(() => expect(composer).toHaveProperty("disabled", true)); + await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy()); + expect(composer).toHaveProperty("disabled", false); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); workState._setProjectWorkState("project-1", { @@ -1930,7 +1932,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy(); }); - it("shows the requester as waiting on the delegated target and keeps input locked", async () => { + it("shows the requester as waiting on the delegated target while keeping input active", async () => { const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); workState._setProjectWorkState("project-1", { @@ -2003,7 +2005,8 @@ describe("CustomAgentChatView", () => { ); expect(await screen.findByText("Main attend Architect")).toBeTruthy(); - expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", true); + expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", false); + expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy(); }); it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => { @@ -5978,6 +5981,208 @@ describe("CustomAgentChatView", () => { expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); + it("queues busy composer submissions and drains them one at a time in FIFO order after Final", async () => { + const harness = createStreamingAgentHarness(); + render( + null) } } as unknown as Gateways}> + + , + ); + + const composer = await screen.findByLabelText(/Message CLI personnalisée/); + await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy()); + fireEvent.change(composer, { target: { value: "active request" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1)); + + expect(composer).toHaveProperty("disabled", false); + fireEvent.change(composer, { target: { value: "second queued" } }); + fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" })); + expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy(); + expect((composer as HTMLTextAreaElement).value).toBe(""); + fireEvent.change(composer, { target: { value: "third queued" } }); + fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" })); + + const queueButton = screen.getByRole("button", { name: "En attente · 2" }); + expect(customAgentChatQueueCount("project-1", "agent-1", "layout-1", "node-1")).toBe(2); + fireEvent.click(queueButton); + const queue = screen.getByRole("region", { name: "File d’attente des messages" }); + expect(within(queue).getByText("second queued")).toBeTruthy(); + expect(within(queue).getByText("third queued")).toBeTruthy(); + expect(within(queue).getByText("Prochain")).toBeTruthy(); + expect(within(queue).getByText("2e")).toBeTruthy(); + + act(() => harness.emit(0, { kind: "final", content: "first final" })); + await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2)); + expect(harness.sendAgentChat).toHaveBeenNthCalledWith( + 2, + "chat-session-1", + "second queued", + expect.any(Function), + ); + expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy(); + expect(customAgentChatQueueCount("project-1", "agent-1", "layout-1", "node-1")).toBe(1); + + act(() => harness.emit(1, { kind: "final", content: "second final" })); + await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(3)); + expect(harness.sendAgentChat).toHaveBeenNthCalledWith( + 3, + "chat-session-1", + "third queued", + expect.any(Function), + ); + }); + + it("edits, deletes, restores and clears queued messages while Stop preserves the queue", async () => { + const harness = createStreamingAgentHarness(); + const cancelAgentChat = vi.fn(async () => ({ + sessionId: "chat-session-1", + outcome: "cancelled" as const, + })); + const agent = { ...harness.agent, cancelAgentChat }; + render( + null) } } as unknown as Gateways}> + + , + ); + + const composer = await screen.findByLabelText(/Message CLI personnalisée/); + await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy()); + fireEvent.change(composer, { target: { value: "active" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1)); + for (const text of ["edit me", "keep me"]) { + fireEvent.change(composer, { target: { value: text } }); + fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" })); + } + + fireEvent.click(screen.getByRole("button", { name: "En attente · 2" })); + fireEvent.click(screen.getAllByRole("button", { name: "Modifier" })[0]); + expect((composer as HTMLTextAreaElement).value).toBe("edit me"); + expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Stop" })); + await waitFor(() => expect(cancelAgentChat).toHaveBeenCalledOnce()); + expect(harness.sendAgentChat).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Supprimer" })); + expect(screen.getByRole("button", { name: "Annuler" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Annuler" })); + expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "En attente · 1" })); + fireEvent.click(screen.getByRole("button", { name: "Tout supprimer" })); + expect(screen.getByRole("button", { name: "Confirmer" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Confirmer" })); + expect(screen.queryByRole("button", { name: /En attente/ })).toBeNull(); + }); + + it("keeps remaining queued messages blocked after a terminal error", async () => { + const harness = createStreamingAgentHarness(); + render( + null) } } as unknown as Gateways}> + + , + ); + + const composer = await screen.findByLabelText(/Message CLI personnalisée/); + await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy()); + fireEvent.change(composer, { target: { value: "active" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1)); + fireEvent.change(composer, { target: { value: "must remain queued" } }); + fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" })); + + act(() => harness.emit(0, { kind: "error", message: "runtime failed" })); + await act(async () => Promise.resolve()); + expect(harness.sendAgentChat).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy(); + expect(screen.getByText("runtime failed")).toBeTruthy(); + }); + + it("shows Final/latest navigation without forcing scroll while reading history", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [ + { kind: "userPrompt" as const, text: "first" }, + { kind: "final" as const, content: "first final" }, + { kind: "userPrompt" as const, text: "second" }, + { kind: "final" as const, content: "second final" }, + ], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + + expect(await screen.findByRole("button", { name: "Aller au Final précédent" })).toBeTruthy(); + const scroll = screen.getByTestId("custom-agent-chat-scroll"); + Object.defineProperty(scroll, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(scroll, "clientHeight", { value: 300, configurable: true }); + scroll.scrollTop = 400; + const scrollTo = vi.fn(); + Object.defineProperty(scroll, "scrollTo", { value: scrollTo, configurable: true }); + fireEvent.scroll(scroll); + fireEvent.click(screen.getByRole("button", { name: "Aller au dernier message" })); + expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" }); + + fireEvent.click(screen.getByRole("button", { name: "Aller au Final précédent" })); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "smooth" }); + }); + it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 366a5004..e55f6f4d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -99,7 +99,9 @@ interface CachedChatView { const chatSessionViewCache = new Map(); const chatConversationViewCache = new Map(); const chatAttachmentDraftCache = new Map(); +const chatMessageQueueCache = new Map(); const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24; +const CHAT_MESSAGE_QUEUE_CACHE_LIMIT = 24; const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; @@ -109,6 +111,7 @@ export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); chatConversationViewCache.clear(); chatAttachmentDraftCache.clear(); + chatMessageQueueCache.clear(); } interface AttachmentDraft { @@ -120,6 +123,43 @@ interface AttachmentDraft { token?: string; } +interface QueuedChatMessage { + id: string; + text: string; + attachments: AttachmentDraft[]; + createdAtMs: number; +} + +function messageQueueCacheKey( + projectId: string, + agentId: string, + layoutId?: string, + nodeId?: string, +): string { + return `${projectId}\u0000${agentId}\u0000${layoutId ?? ""}\u0000${nodeId ?? ""}`; +} + +function cacheMessageQueue(key: string, messages: QueuedChatMessage[]): void { + chatMessageQueueCache.delete(key); + if (messages.length > 0) chatMessageQueueCache.set(key, messages); + while (chatMessageQueueCache.size > CHAT_MESSAGE_QUEUE_CACHE_LIMIT) { + const oldest = chatMessageQueueCache.keys().next().value as string | undefined; + if (!oldest) break; + chatMessageQueueCache.delete(oldest); + } +} + +export function customAgentChatQueueCount( + projectId: string, + agentId: string, + layoutId?: string, + nodeId?: string, +): number { + return chatMessageQueueCache.get( + messageQueueCacheKey(projectId, agentId, layoutId, nodeId), + )?.length ?? 0; +} + function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null { return scope ? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}` @@ -997,12 +1037,30 @@ export function CustomAgentChatView({ layoutId && nodeId ? { projectId, layoutId, nodeId } : null ); const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope); + const initialMessageQueueKey = messageQueueCacheKey( + projectId, + agentId, + layoutId, + nodeId, + ); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState(() => initialAttachmentCacheKey ? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? []) : [], ); + const [queuedMessages, setQueuedMessages] = useState( + () => chatMessageQueueCache.get(initialMessageQueueKey) ?? [], + ); + const [queueOpen, setQueueOpen] = useState(false); + const [clearQueueConfirmation, setClearQueueConfirmation] = useState(false); + const [deletedQueueMessage, setDeletedQueueMessage] = useState<{ + message: QueuedChatMessage; + index: number; + } | null>(null); + const [queueAnnouncement, setQueueAnnouncement] = useState(""); + const [queueDrainReady, setQueueDrainReady] = useState(false); + const [showLatestButton, setShowLatestButton] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [slashMenuOpen, setSlashMenuOpen] = useState(false); const [slashActiveIndex, setSlashActiveIndex] = useState(0); @@ -1014,6 +1072,7 @@ export function CustomAgentChatView({ ConversationTranscriptUnavailableReason | "conversationNotFound" | null >(null); const [busy, setBusy] = useState(false); + const [transmissionActive, setTransmissionActive] = useState(false); const [stopping, setStopping] = useState(false); const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [error, setError] = useState(null); @@ -1040,6 +1099,7 @@ export function CustomAgentChatView({ const draftSaveSeqRef = useRef(0); const interactionSeqRef = useRef(0); const activeInteractionIdRef = useRef(null); + const transmissionInFlightRef = useRef(false); const cancelledInteractionIdsRef = useRef(new Set()); const ignoreUnscopedChunksAfterStopRef = useRef(false); const stopInFlightRef = useRef(false); @@ -1055,6 +1115,10 @@ export function CustomAgentChatView({ const attachmentsRef = useRef([]); attachmentsRef.current = attachments; const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey); + const queuedMessagesRef = useRef(queuedMessages); + queuedMessagesRef.current = queuedMessages; + const messageQueueKeyRef = useRef(initialMessageQueueKey); + const followsLatestRef = useRef(true); const imageTokenSeqRef = useRef(0); const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); @@ -1182,6 +1246,10 @@ export function CustomAgentChatView({ return layoutId && nodeId ? { projectId, layoutId, nodeId } : null; }, [explicitDraftScope, layoutId, nodeId, projectId]); const attachmentCacheKey = attachmentDraftCacheKey(draftScope); + const messageQueueKey = useMemo( + () => messageQueueCacheKey(projectId, agentId, layoutId, nodeId), + [agentId, layoutId, nodeId, projectId], + ); const currentConversationCacheKey = useMemo( () => conversationCacheKey(projectId, agentId, conversationId), [agentId, conversationId, projectId], @@ -1198,6 +1266,7 @@ export function CustomAgentChatView({ const [composerHistoryIndex, setComposerHistoryIndex] = useState(null); const displayTurns = turns; + const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final"); const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const [requesterDelegation, setRequesterDelegation] = useState({ @@ -1285,6 +1354,26 @@ export function CustomAgentChatView({ setDraft(text); }, []); + const updateQueuedMessages = useCallback( + ( + updater: + | QueuedChatMessage[] + | ((current: QueuedChatMessage[]) => QueuedChatMessage[]), + ) => { + const next = typeof updater === "function" + ? updater(queuedMessagesRef.current) + : updater; + queuedMessagesRef.current = next; + cacheMessageQueue(messageQueueKeyRef.current, next); + setQueuedMessages(next); + if (next.length === 0) { + setQueueOpen(false); + setClearQueueConfirmation(false); + } + }, + [], + ); + const recallComposerHistory = useCallback( (direction: "previous" | "next") => { if (composerHistory.length === 0) return; @@ -1388,6 +1477,19 @@ export function CustomAgentChatView({ ); }, [attachmentCacheKey]); + useEffect(() => { + const previousKey = messageQueueKeyRef.current; + cacheMessageQueue(previousKey, queuedMessagesRef.current); + messageQueueKeyRef.current = messageQueueKey; + const restored = chatMessageQueueCache.get(messageQueueKey) ?? []; + queuedMessagesRef.current = restored; + setQueuedMessages(restored); + setQueueOpen(false); + setClearQueueConfirmation(false); + setDeletedQueueMessage(null); + setQueueDrainReady(false); + }, [messageQueueKey]); + useEffect(() => { void refreshRequesterDelegation(); }, [refreshRequesterDelegation]); @@ -1448,6 +1550,7 @@ export function CustomAgentChatView({ }); } else if (event.type === "agentRestartCompleted") { changed = true; + setQueueDrainReady(true); nextTurns.push({ role: "restart", key, @@ -1518,6 +1621,7 @@ export function CustomAgentChatView({ } updateChatViewState((prev) => foldChunk(prev, chunk)); if (chunk.kind === "final" || chunk.kind === "error") { + setQueueDrainReady(chunk.kind === "final"); if (activeInteractionIdRef.current) { markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; @@ -1880,9 +1984,65 @@ export function CustomAgentChatView({ ], ); + const prefersReducedMotion = useCallback( + () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + [], + ); + + const scrollToLatest = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + const top = element.scrollHeight; + if (element.scrollTo) { + element.scrollTo({ + top, + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + } else { + element.scrollTop = top; + } + followsLatestRef.current = true; + setShowLatestButton(false); + }, [prefersReducedMotion]); + + const updateLatestVisibility = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + const atBottom = + element.scrollHeight - element.scrollTop - element.clientHeight <= 24; + followsLatestRef.current = atBottom; + setShowLatestButton(!atBottom); + }, []); + + const scrollToPreviousFinal = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + const finals = Array.from( + element.querySelectorAll("[data-chat-final]"), + ); + if (finals.length === 0) return; + const currentTop = element.scrollTop; + const earlier = finals.filter((final) => final.offsetTop < currentTop - 8); + const target = earlier.at(-1) ?? finals.at(-1); + if (!target) return; + const top = Math.max(0, target.offsetTop - 8); + if (element.scrollTo) { + element.scrollTo({ + top, + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + } else { + element.scrollTop = top; + } + followsLatestRef.current = false; + setShowLatestButton(true); + }, [prefersReducedMotion]); + useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; + const element = scrollRef.current; + if (!element || !followsLatestRef.current) return; + element.scrollTop = element.scrollHeight; + setShowLatestButton(false); }, [displayTurns]); useEffect(() => { @@ -2044,13 +2204,12 @@ export function CustomAgentChatView({ openRetryNonce, ]); - const canSend = useMemo( + const canSubmit = useMemo( () => supported && (Boolean(draft.trim()) || attachments.length > 0) && - !effectiveBusy && !opening, - [supported, draft, attachments.length, effectiveBusy, opening], + [supported, draft, attachments.length, opening], ); const unavailableLinkedTranscript = displayTurns.length === 0 && @@ -2317,9 +2476,7 @@ export function CustomAgentChatView({ } } - async function send() { - const text = draft.trim(); - if (!canSend || !agent.sendAgentChat) return; + function rememberComposerText(text: string) { if (text) { const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text); composerHistoryRef.current = nextHistory; @@ -2331,12 +2488,42 @@ export function CustomAgentChatView({ setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; } + } + + function clearComposerAfterSubmit() { + setDraft(""); + setAttachments([]); + imageTokenSeqRef.current = 0; + void clearPersistedDraft(); + } + + function enqueueComposerMessage(text: string) { + const message: QueuedChatMessage = { + id: `${agentId}:queued:${Date.now()}:${Math.random().toString(36).slice(2)}`, + text, + attachments: [...attachments], + createdAtMs: Date.now(), + }; + updateQueuedMessages((current) => [...current, message]); + clearComposerAfterSubmit(); + setDeletedQueueMessage(null); + setQueueAnnouncement( + tr("Message ajouté à la file d’attente", "Message added to the queue"), + ); + } + + async function transmitMessage(message: QueuedChatMessage) { + const text = message.text; + if (!agent.sendAgentChat || transmissionInFlightRef.current) return; + setQueueDrainReady(false); const slashInvocation = slashCommandInvocation(text); - if (slashInvocation) { + if (slashInvocation && message.id.endsWith(":composer")) { await executeSlashCommand(slashInvocation); return; } - const outgoingAttachments = attachments; + transmissionInFlightRef.current = true; + setTransmissionActive(true); + const outgoingAttachments = message.attachments; const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentLabels = outgoingAttachments.map((item) => item.label); const displayText = text || "Pièce jointe"; @@ -2350,10 +2537,6 @@ export function CustomAgentChatView({ markProjectCliInteractionFinished(projectId, interactionId); activeInteractionIdRef.current = null; }; - setDraft(""); - setAttachments([]); - imageTokenSeqRef.current = 0; - void clearPersistedDraft(); setBusy(true); setError(null); updateChatViewState((prev) => ({ @@ -2401,6 +2584,7 @@ export function CustomAgentChatView({ } catch (recoveryError) { finishInteraction(); setBusy(false); + setQueueDrainReady(false); setError({ message: describe(recoveryError), openFailure: false }); updateChatViewState((prev) => ({ ...prev, @@ -2414,14 +2598,36 @@ export function CustomAgentChatView({ } finishInteraction(); setBusy(false); + setQueueDrainReady(false); setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }], })); + } finally { + transmissionInFlightRef.current = false; + setTransmissionActive(false); } } + async function send() { + const text = draft.trim(); + if (!canSubmit || !agent.sendAgentChat) return; + rememberComposerText(text); + if (effectiveBusy || transmissionInFlightRef.current) { + enqueueComposerMessage(text); + return; + } + const message: QueuedChatMessage = { + id: `${agentId}:${++interactionSeqRef.current}:composer`, + text, + attachments: [...attachments], + createdAtMs: Date.now(), + }; + clearComposerAfterSubmit(); + await transmitMessage(message); + } + async function stopCurrentRequest() { const sid = sessionRef.current; if ( @@ -2434,6 +2640,7 @@ export function CustomAgentChatView({ return; } stopInFlightRef.current = true; + setQueueDrainReady(false); ignoreUnscopedChunksAfterStopRef.current = true; const interactionBeingCancelled = activeInteractionIdRef.current; if (interactionBeingCancelled) { @@ -2503,6 +2710,77 @@ export function CustomAgentChatView({ } } + function editQueuedMessage(message: QueuedChatMessage) { + updateQueuedMessages((current) => + current.filter((candidate) => candidate.id !== message.id), + ); + editDraft(message.text); + setAttachments(message.attachments); + setDeletedQueueMessage(null); + setQueueAnnouncement( + tr("Message remis dans le champ de saisie", "Message returned to the composer"), + ); + window.setTimeout(() => composerRef.current?.focus(), 0); + } + + function deleteQueuedMessage(message: QueuedChatMessage) { + const index = queuedMessagesRef.current.findIndex( + (candidate) => candidate.id === message.id, + ); + if (index < 0) return; + updateQueuedMessages((current) => + current.filter((candidate) => candidate.id !== message.id), + ); + setDeletedQueueMessage({ message, index }); + setQueueAnnouncement(tr("Message supprimé", "Message deleted")); + } + + function undoDeleteQueuedMessage() { + if (!deletedQueueMessage) return; + updateQueuedMessages((current) => { + const next = [...current]; + next.splice( + Math.min(deletedQueueMessage.index, next.length), + 0, + deletedQueueMessage.message, + ); + return next; + }); + setDeletedQueueMessage(null); + setQueueAnnouncement(tr("Suppression annulée", "Deletion undone")); + } + + function clearQueuedMessages() { + updateQueuedMessages([]); + setDeletedQueueMessage(null); + setQueueAnnouncement( + tr("File d’attente supprimée", "Queue cleared"), + ); + } + + useEffect(() => { + if ( + !queueDrainReady || + effectiveBusy || + opening || + !supported || + transmissionActive || + queuedMessages.length === 0 + ) { + return; + } + const next = queuedMessages[0]; + updateQueuedMessages((current) => + current[0]?.id === next.id + ? current.slice(1) + : current.filter((candidate) => candidate.id !== next.id), + ); + setQueueAnnouncement( + tr("Envoi du prochain message en attente", "Sending the next queued message"), + ); + void transmitMessage(next); + }, [effectiveBusy, opening, queueDrainReady, queuedMessages, supported, transmissionActive]); + const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId; return ( @@ -2545,6 +2823,16 @@ export function CustomAgentChatView({ + {hasPreviousFinal && ( + + )} {!supported && (

@@ -2694,6 +2982,7 @@ export function CustomAgentChatView({ ref={scrollRef} data-testid="custom-agent-chat-scroll" className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" + onScroll={updateLatestVisibility} >

+ {showLatestButton && ( + + )} +
+ {queueAnnouncement} +
+ {(queuedMessages.length > 0 || deletedQueueMessage) && ( +
+ {queuedMessages.length > 0 && ( + + )} + {deletedQueueMessage && ( +
+ {tr("Message supprimé", "Message deleted")} + +
+ )} + {queueOpen && queuedMessages.length > 0 && ( +
+
+ + {tr("Messages en attente", "Queued messages")} · {queuedMessages.length} + + {clearQueueConfirmation ? ( +
+ + +
+ ) : ( + + )} +
+
    + {queuedMessages.map((message, index) => ( +
  1. + + {index === 0 + ? tr("Prochain", "Next") + : tr(`${index + 1}e`, `#${index + 1}`)} + +
    +

    + {message.text || tr("Pièce jointe", "Attachment")} +

    +
    + + +
    +
    +
  2. + ))} +
+
+ )} +
+ )} {attachments.length > 0 && (
{attachments.map((item) => ( @@ -2854,7 +3255,7 @@ export function CustomAgentChatView({ )} rows={2} value={draft} - disabled={!supported || opening || effectiveBusy} + disabled={!supported || opening} placeholder={t("custom.message")} style={{ height: clampComposerHeight(composerHeightPx) }} onChange={(e) => editDraft(e.target.value)} @@ -2912,26 +3313,34 @@ export function CustomAgentChatView({ size="sm" variant="ghost" className="shrink-0 whitespace-nowrap" - disabled={!supported || opening || effectiveBusy} + disabled={!supported || opening} onClick={() => void pickAttachment()} > {t("custom.attach")} + {effectiveBusy && ( + + )}
@@ -2994,7 +3403,7 @@ function ChatBubble({ } if (turn.role === "final") { return ( -
+
{ expect(restoredA.conversationId).toBe(persistedA.conversationId); }); - it("keeps custom CLI visible while busy and locks only the composer (#247)", async () => { + it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => { const gateways = await seeded(structuredProfile); const layout = gateways.layout as MockLayoutGateway; + const agent = gateways.agent as MockAgentGateway; + const sendAgentChat = vi.spyOn(agent, "sendAgentChat"); const workState = gateways.workState as MockWorkStateGateway; const tree = await layout.loadLayout("p1"); const leaf = leaves(tree)[0]; @@ -328,12 +330,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.queryByRole("button", { name: `cancel current turn ${leaf.id}`, })).toBeNull(); - await waitFor(() => - expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty( - "disabled", - true, - ), - ); + const composer = await screen.findByLabelText(/Message CLI personnalisée/); + await waitFor(() => expect(composer).toHaveProperty("disabled", false)); + expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); + const queueAction = screen.getByRole("button", { name: "Mettre en attente" }); + fireEvent.change(composer, { target: { value: "Question suivante" } }); + fireEvent.click(queueAction); + + expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy(); + expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy(); + expect((composer as HTMLTextAreaElement).value).toBe(""); + expect(sendAgentChat).not.toHaveBeenCalled(); }); it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 78d4b7a0..6d41a3cb 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -40,6 +40,7 @@ import { import { PluginLayoutCellView } from "@/features/plugins"; import { CustomAgentChatView, + customAgentChatQueueCount, isCustomAgentCliAvailable, modelServerOverlayText, describeModelServerDownload, @@ -338,12 +339,14 @@ function goToCell(nodeId: string): void { function StoppedAgentCellPanel({ agentName, + queuedMessageCount, restarting, error, onRestart, onRefresh, }: { agentName: string; + queuedMessageCount: number; restarting: boolean; error: boolean; onRestart: () => void; @@ -360,6 +363,14 @@ function StoppedAgentCellPanel({ {tr("Arrêté", "Stopped")}
+ {queuedMessageCount > 0 && ( +

+ {tr( + "Envoi en attente — l’agent est arrêté", + "Message pending — the agent is stopped", + )} · {queuedMessageCount} +

+ )}

{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}

@@ -376,11 +387,15 @@ function StoppedAgentCellPanel({