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:
@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user