merge(frontend): feature #352 — file d'attente des messages CLI custom pendant busy et navigation Final précédent / dernier message (QA verte)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 22:58:36 +02:00
6 changed files with 702 additions and 45 deletions

View File

@ -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(
<DIProvider gateways={{ agent: harness.agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<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"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider gateways={{ agent: harness.agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<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>,
);
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(),

View File

@ -99,7 +99,9 @@ interface CachedChatView {
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
const chatMessageQueueCache = new Map<string, QueuedChatMessage[]>();
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<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
() => 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<SlashCommand[]>([]);
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<ChatErrorState | null>(null);
@ -1040,6 +1099,7 @@ export function CustomAgentChatView({
const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
const activeInteractionIdRef = useRef<string | null>(null);
const transmissionInFlightRef = useRef(false);
const cancelledInteractionIdsRef = useRef(new Set<string>());
const ignoreUnscopedChunksAfterStopRef = useRef(false);
const stopInFlightRef = useRef(false);
@ -1055,6 +1115,10 @@ export function CustomAgentChatView({
const attachmentsRef = useRef<AttachmentDraft[]>([]);
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<number | null>(null);
const displayTurns = turns;
const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final");
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] =
useState<RequesterDelegationState>({
@ -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<HTMLElement>("[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({
</div>
</div>
</div>
{hasPreviousFinal && (
<button
type="button"
aria-label={tr("Aller au Final précédent", "Go to previous Final")}
className="absolute left-1/2 top-12 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
onClick={scrollToPreviousFinal}
>
↑
</button>
)}
{!supported && (
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
@ -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}
>
<div className="shrink-0">
<AnnouncementsPreview
@ -2768,6 +3057,118 @@ export function CustomAgentChatView({
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
{showLatestButton && (
<button
type="button"
aria-label={tr("Aller au dernier message", "Go to latest message")}
className="absolute -top-12 left-1/2 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
onClick={scrollToLatest}
>
↓
</button>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{queueAnnouncement}
</div>
{(queuedMessages.length > 0 || deletedQueueMessage) && (
<div className="relative flex min-w-0 items-center gap-2">
{queuedMessages.length > 0 && (
<button
type="button"
className="flex min-h-11 items-center rounded-md border border-border bg-surface px-3 text-xs font-semibold text-content hover:border-border-strong"
aria-expanded={queueOpen}
aria-controls={`message-queue-${domId}`}
onClick={() => setQueueOpen((open) => !open)}
>
{tr("En attente", "Queued")} · {queuedMessages.length}
</button>
)}
{deletedQueueMessage && (
<div role="status" className="flex min-h-11 min-w-0 items-center gap-2 text-xs text-muted">
<span>{tr("Message supprimé", "Message deleted")}</span>
<button
type="button"
className="min-h-11 px-2 font-semibold text-primary"
onClick={undoDeleteQueuedMessage}
>
{tr("Annuler", "Undo")}
</button>
</div>
)}
{queueOpen && queuedMessages.length > 0 && (
<div
id={`message-queue-${domId}`}
role="region"
aria-label={tr("File d’attente des messages", "Message queue")}
className="absolute bottom-full left-0 z-30 mb-2 flex max-h-[min(22rem,60vh)] w-[min(34rem,calc(100vw-2rem))] max-w-full flex-col overflow-hidden rounded-md border border-border bg-raised shadow-xl"
>
<div className="flex min-h-11 items-center justify-between gap-2 border-b border-border px-3">
<strong className="text-xs">
{tr("Messages en attente", "Queued messages")} · {queuedMessages.length}
</strong>
{clearQueueConfirmation ? (
<div className="flex items-center gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs text-muted"
onClick={() => setClearQueueConfirmation(false)}
>
{tr("Annuler", "Cancel")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-danger"
onClick={clearQueuedMessages}
>
{tr("Confirmer", "Confirm")}
</button>
</div>
) : (
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => setClearQueueConfirmation(true)}
>
{tr("Tout supprimer", "Clear all")}
</button>
)}
</div>
<ol className="min-h-0 overflow-y-auto p-1">
{queuedMessages.map((message, index) => (
<li key={message.id} className="flex min-w-0 gap-2 rounded px-2 py-2 hover:bg-surface">
<span className="w-14 shrink-0 pt-1 text-[11px] font-semibold text-primary">
{index === 0
? tr("Prochain", "Next")
: tr(`${index + 1}e`, `#${index + 1}`)}
</span>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-content">
{message.text || tr("Pièce jointe", "Attachment")}
</p>
<div className="mt-1 flex flex-wrap gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-primary"
onClick={() => editQueuedMessage(message)}
>
{tr("Modifier", "Edit")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => deleteQueuedMessage(message)}
>
{tr("Supprimer", "Delete")}
</button>
</div>
</div>
</li>
))}
</ol>
</div>
)}
</div>
)}
{attachments.length > 0 && (
<div className="flex min-w-0 flex-wrap gap-2">
{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")}
</Button>
{effectiveBusy && (
<Button
size="sm"
variant="danger"
className="min-h-11 shrink-0 whitespace-nowrap"
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
loading={stopping}
title={t("custom.stop")}
aria-label={t("custom.stop")}
onClick={() => void stopCurrentRequest()}
>
{stopping ? t("custom.stopping") : t("custom.stop")}
</Button>
)}
<Button
size="sm"
variant={effectiveBusy ? "danger" : undefined}
className="shrink-0 whitespace-nowrap"
disabled={
effectiveBusy
? !supported || !currentSession || !agent.cancelAgentChat || stopping
: !canSend
}
loading={stopping}
title={effectiveBusy ? t("custom.stop") : undefined}
aria-label={effectiveBusy ? t("custom.stop") : undefined}
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
className="min-h-11 shrink-0 whitespace-nowrap"
disabled={!canSubmit}
onClick={() => void send()}
>
{stopping ? t("custom.stopping") : effectiveBusy ? t("custom.stop") : t("custom.send")}
{effectiveBusy
? tr("Mettre en attente", "Queue message")
: t("custom.send")}
</Button>
</div>
</div>
@ -2994,7 +3403,7 @@ function ChatBubble({
}
if (turn.role === "final") {
return (
<div className="flex min-w-0 justify-end">
<div data-chat-final="true" className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<ChatMessageMeta
label={tr("Terminé", "Completed")}

View File

@ -23,6 +23,9 @@ export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";
export type { ResumeProjectViewModel } from "./useResumeProject";
export { CustomAgentChatView } from "./CustomAgentChatView";
export {
CustomAgentChatView,
customAgentChatQueueCount,
} from "./CustomAgentChatView";
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
export { isCustomAgentCliAvailable } from "./customCliEligibility";

View File

@ -286,9 +286,11 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
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 () => {

View File

@ -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({
<span className="stopped-agent-cell__badge">{tr("Arrêté", "Stopped")}</span>
</div>
<div className="stopped-agent-cell__content">
{queuedMessageCount > 0 && (
<p role="status" className="stopped-agent-cell__queued">
{tr(
"Envoi en attente — l’agent est arrêté",
"Message pending — the agent is stopped",
)} · {queuedMessageCount}
</p>
)}
<p className="stopped-agent-cell__message">
{error ? tr("Erreur de relance", "Restart failed") : restarting ? tr("Relance…", "Restarting…") : tr("Cette session est terminée.", "This session has ended.")}
</p>
@ -376,11 +387,15 @@ function StoppedAgentCellPanel({
<div className="stopped-agent-cell__actions">
<button
type="button"
className="stopped-agent-cell__button stopped-agent-cell__button--primary"
className={`stopped-agent-cell__button stopped-agent-cell__button--primary${queuedMessageCount > 0 ? " stopped-agent-cell__button--queued" : ""}`}
onClick={onRestart}
disabled={restarting}
>
{restarting ? tr("Relance…", "Restarting…") : tr("Relancer", "Restart")}
{restarting
? tr("Relance…", "Restarting…")
: queuedMessageCount > 0
? tr("Relancer l’agent", "Restart agent")
: tr("Relancer", "Restart")}
</button>
<button
type="button"
@ -1658,6 +1673,12 @@ function LeafView({
) : agentId && stoppedAgentCell ? (
<StoppedAgentCellPanel
agentName={pinnedAgent?.name ?? tr("Agent", "Agent")}
queuedMessageCount={customAgentChatQueueCount(
projectId,
agentId,
layoutId,
id,
)}
restarting={restartPending}
error={restartError}
onRestart={() => void restartStoppedAgent()}

View File

@ -204,6 +204,14 @@
line-height: 1.35;
}
.stopped-agent-cell__queued {
margin: 0 0 8px;
color: var(--color-warning);
font-size: 12px;
font-weight: 650;
line-height: 1.35;
}
.stopped-agent-cell__help {
margin: 4px 0 0;
color: var(--color-muted);
@ -249,6 +257,10 @@
background: var(--color-primary-hover);
}
.stopped-agent-cell__button--queued {
min-height: 44px;
}
@media (max-width: 640px) {
.stopped-agent-cell {
padding: 34px 10px 10px;