feat(frontend): queue custom CLI messages while agent is busy and add Final/latest-message navigation — #352 (QA verte)

Custom CLI composer stays active during busy turns: submitting while the
agent works enqueues the message instead of sending it. Local FIFO queue
scoped per cell (projectId+agentId+layoutId+nodeId), entries carry id,
text, attachments snapshot and createdAtMs; single in-flight
transmission, entry removed at send time, auto-drain only on final
chunk, queue preserved on error/Stop with 'Relancer l'agent' action on
stopped cells. 'En attente · N' panel anchored above the composer:
order, 2-line preview, per-entry edit/delete with transient undo, clear
all with confirmation; main button switches Send / 'Mettre en attente'
with live announcement; layout contract updated (CLI visible while
busy, textarea enabled, Stop available). Chat navigation: round up-arrow
under the agent bar jumps to previous Final, round down-arrow above the
composer appears when not at bottom to jump to latest message,
auto-scroll suspended while reading history, prefers-reduced-motion and
44px a11y targets respected.
Ports unchanged (AgentGateway send/cancel/reattach, restartCellAgentRuntime).
QA verte reconfirmée par Git: targeted suites 115/115
(CustomAgentChatView/LayoutGrid/LayoutGrid.chat), git diff --check;
QA: npm run typecheck OK, npm run build OK.
Réserves documentées: queue renderer-local (non persistée après restart
complet), validation visuelle cellules très étroites restante.

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

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")}