feat: refresh queued message controls

Queued messages now match the reverted message dock style
Added per-message edit and send actions
Sending one queued message no longer sends the full queue
This commit is contained in:
Bohdan Triapitsyn
2026-05-27 17:13:45 +03:00
parent 62a5a254b3
commit 9ed2e39b08
10 changed files with 114 additions and 45 deletions
+38 -19
View File
@@ -1332,6 +1332,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
); );
const addToQueue = useMessageQueueStore((state) => state.addToQueue); const addToQueue = useMessageQueueStore((state) => state.addToQueue);
const clearQueue = useMessageQueueStore((state) => state.clearQueue); const clearQueue = useMessageQueueStore((state) => state.clearQueue);
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Inline comment drafts // Inline comment drafts
const draftCount = useInlineCommentDraftStore( const draftCount = useInlineCommentDraftStore(
@@ -1579,6 +1580,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Keep a ref to handleSubmit so callbacks don't depend on it. // Keep a ref to handleSubmit so callbacks don't depend on it.
type SubmitOptions = { type SubmitOptions = {
queuedOnly?: boolean; queuedOnly?: boolean;
queuedMessageId?: string;
}; };
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {}); const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
@@ -1627,6 +1629,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, 0); }, 0);
}, []); }, []);
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId });
}, []);
const handleOpenAgentPanel = React.useCallback(() => { const handleOpenAgentPanel = React.useCallback(() => {
setMobileControlsPanel('agent'); setMobileControlsPanel('agent');
}, []); }, []);
@@ -1645,15 +1651,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handleSubmit = async (options?: SubmitOptions) => { const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false; const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
const inputSnapshot = getCurrentInputSnapshot(); const inputSnapshot = getCurrentInputSnapshot();
const queuedMessagesToSend = queuedMessageId
? queuedMessages.filter((message) => message.id === queuedMessageId)
: queuedMessages;
if (queuedOnly) { if (queuedOnly) {
if (!hasQueuedMessages || !currentSessionId) return; if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) { } else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
return; return;
} }
if (!currentProviderId || !currentModelId) { const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined;
const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId;
const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId;
const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName;
const variantToSend = capturedSendConfig?.variant ?? currentVariant;
if (!providerIdToSend || !modelIdToSend) {
console.warn('Cannot send message: provider or model not selected'); console.warn('Cannot send message: provider or model not selected');
return; return;
} }
@@ -1675,8 +1691,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const syntheticParts = consumePendingSyntheticParts(); const syntheticParts = consumePendingSyntheticParts();
// Process queued messages first // Process queued messages first
for (let i = 0; i < queuedMessages.length; i++) { for (let i = 0; i < queuedMessagesToSend.length; i++) {
const queuedMsg = queuedMessages[i]; const queuedMsg = queuedMessagesToSend[i];
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
addMentionedSkills(queuedText); addMentionedSkills(queuedText);
@@ -1715,7 +1731,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
agentMentionName = mention.name; agentMentionName = mention.name;
} }
if (queuedMessages.length === 0) { if (queuedMessagesToSend.length === 0) {
// No queue - current input is primary // No queue - current input is primary
primaryText = messageText; primaryText = messageText;
primaryAttachments = [...attachmentsToSend, ...mentionAttachments]; primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
@@ -1735,7 +1751,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
if (drafts.length > 0) { if (drafts.length > 0) {
if (queuedMessages.length === 0) { if (queuedMessagesToSend.length === 0) {
primaryText = appendInlineComments(primaryText, drafts); primaryText = appendInlineComments(primaryText, drafts);
} else if (additionalParts.length > 0) { } else if (additionalParts.length > 0) {
const lastPart = additionalParts[additionalParts.length - 1]; const lastPart = additionalParts[additionalParts.length - 1];
@@ -1786,7 +1802,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
// Clear queue and input // Clear queue and input
if (currentSessionId && hasQueuedMessages) { if (currentSessionId && queuedMessageId) {
removeFromQueue(currentSessionId, queuedMessageId);
} else if (currentSessionId && hasQueuedMessages) {
clearQueue(currentSessionId); clearQueue(currentSessionId);
} }
if (!queuedOnly) { if (!queuedOnly) {
@@ -1862,13 +1880,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock }); const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock });
await sendMessage( await sendMessage(
visibleText, visibleText,
currentProviderId, providerIdToSend,
currentModelId, modelIdToSend,
currentAgentName, agentNameToSend,
[], [],
agentMentionName, agentMentionName,
[{ text: instructionsText, synthetic: true }], [{ text: instructionsText, synthetic: true }],
currentVariant, variantToSend,
inputMode, inputMode,
); );
scrollToBottom?.(); scrollToBottom?.();
@@ -1884,13 +1902,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const instructionsText = await renderMagicPrompt('session.review.instructions'); const instructionsText = await renderMagicPrompt('session.review.instructions');
await sendMessage( await sendMessage(
visibleText, visibleText,
currentProviderId, providerIdToSend,
currentModelId, modelIdToSend,
currentAgentName, agentNameToSend,
[], [],
agentMentionName, agentMentionName,
[{ text: instructionsText, synthetic: true }], [{ text: instructionsText, synthetic: true }],
currentVariant, variantToSend,
inputMode, inputMode,
); );
scrollToBottom?.(); scrollToBottom?.();
@@ -1933,13 +1951,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const sendPromise = sendMessage( const sendPromise = sendMessage(
primaryText, primaryText,
currentProviderId, providerIdToSend,
currentModelId, modelIdToSend,
currentAgentName, agentNameToSend,
primaryAttachments, primaryAttachments,
agentMentionName, agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined, additionalParts.length > 0 ? additionalParts : undefined,
currentVariant, variantToSend,
inputMode inputMode
); );
@@ -3772,6 +3790,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} /> <AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
<QueuedMessageChips <QueuedMessageChips
onEditMessage={handleQueuedMessageEdit} onEditMessage={handleQueuedMessageEdit}
onSendMessage={handleQueuedMessageSend}
/> />
{hasDrafts && ( {hasDrafts && (
<div className="flex flex-wrap items-center gap-2 pb-2"> <div className="flex flex-wrap items-center gap-2 pb-2">
@@ -4,14 +4,16 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { Button } from '@/components/ui/button';
interface QueuedMessageChipProps { interface QueuedMessageChipProps {
message: QueuedMessage; message: QueuedMessage;
sessionId: string; sessionId: string;
onEdit: (message: QueuedMessage) => void; onEdit: (message: QueuedMessage) => void;
onSend: (message: QueuedMessage) => void;
} }
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => { const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => {
const { t } = useI18n(); const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
@@ -29,24 +31,31 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
const attachmentCount = message.attachments?.length ?? 0; const attachmentCount = message.attachments?.length ?? 0;
return ( return (
<div className="flex w-full items-center gap-1.5 text-sm h-5 px-1"> <div className="flex min-w-0 items-center gap-2 py-1">
<button <span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
{firstLine || t('chat.queuedMessage.empty')}
{attachmentCount > 0 && (
<span className="ml-1 text-muted-foreground">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span>
)}
</span>
<Button
type="button" type="button"
variant="secondary"
size="xs"
onClick={() => onEdit(message)} onClick={() => onEdit(message)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
> >
<Icon name="message-2" className="h-4 w-4 flex-shrink-0 text-muted-foreground" <Icon name="edit" className="h-3 w-3" aria-hidden="true" />
/> {t('chat.queuedMessage.edit')}
<span className="text-muted-foreground flex-shrink-0"> </Button>
Queued <Button
{attachmentCount > 0 && ( type="button"
<span className="ml-1">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span> variant="secondary"
)} size="xs"
</span> onClick={() => onSend(message)}
<span className="text-foreground truncate"> >
{firstLine || t('chat.queuedMessage.empty')} <Icon name="send-plane" className="h-3 w-3" aria-hidden="true" />
</span> {t('chat.queuedMessage.send')}
</button> </Button>
<button <button
type="button" type="button"
onClick={() => removeFromQueue(sessionId, message.id)} onClick={() => removeFromQueue(sessionId, message.id)}
@@ -63,11 +72,13 @@ QueuedMessageChip.displayName = 'QueuedMessageChip';
interface QueuedMessageChipsProps { interface QueuedMessageChipsProps {
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void; onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
onSendMessage: (messageId: string) => void;
} }
const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => { export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const queuedMessages = useMessageQueueStore( const queuedMessages = useMessageQueueStore(
React.useCallback( React.useCallback(
@@ -93,20 +104,35 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
} }
}, [currentSessionId, popToInput, onEditMessage]); }, [currentSessionId, popToInput, onEditMessage]);
const handleSend = React.useCallback((message: QueuedMessage) => {
onSendMessage(message.id);
}, [onSendMessage]);
if (queuedMessages.length === 0 || !currentSessionId) { if (queuedMessages.length === 0 || !currentSessionId) {
return null; return null;
} }
return ( return (
<div className="pb-2 w-full px-1 space-y-1"> <div className="pb-2 w-full px-1">
{queuedMessages.map((message) => ( <div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
<QueuedMessageChip <div className="flex w-full items-center gap-2 px-3 py-2 text-left">
key={message.id} <span className="typography-ui-label font-medium text-foreground flex-shrink-0">
message={message} {t('chat.queuedMessage.title')} {queuedMessages.length}
sessionId={currentSessionId} </span>
onEdit={handleEdit} <Icon name="time" className="ml-auto h-4 w-4 text-muted-foreground" aria-hidden="true" />
/> </div>
))} <div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
onEdit={handleEdit}
onSend={handleSend}
/>
))}
</div>
</div>
</div> </div>
); );
}); });
+3
View File
@@ -1519,6 +1519,9 @@ export const dict = {
'chat.fileMentionAutocomplete.empty': 'No matches found', 'chat.fileMentionAutocomplete.empty': 'No matches found',
'chat.queuedMessage.attachments': '+{count} file(s)', 'chat.queuedMessage.attachments': '+{count} file(s)',
'chat.queuedMessage.empty': '(empty)', 'chat.queuedMessage.empty': '(empty)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': 'Remove from queue', 'chat.queuedMessage.removeAria': 'Remove from queue',
'chat.container.returnToParent.aria': 'Return to parent session', 'chat.container.returnToParent.aria': 'Return to parent session',
'chat.container.returnToParent.titleNamed': 'Return to: {title}', 'chat.container.returnToParent.titleNamed': 'Return to: {title}',
+3
View File
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
"chat.fileMentionAutocomplete.empty": "No se encontraron coincidencias", "chat.fileMentionAutocomplete.empty": "No se encontraron coincidencias",
"chat.queuedMessage.attachments": "+{count} archivo(s)", "chat.queuedMessage.attachments": "+{count} archivo(s)",
"chat.queuedMessage.empty": "(vacío)", "chat.queuedMessage.empty": "(vacío)",
"chat.queuedMessage.title": "Queued messages",
"chat.queuedMessage.edit": "edit",
"chat.queuedMessage.send": "send",
"chat.queuedMessage.removeAria": "Eliminar de la cola", "chat.queuedMessage.removeAria": "Eliminar de la cola",
"chat.container.returnToParent.aria": "Volver a la sesión principal", "chat.container.returnToParent.aria": "Volver a la sesión principal",
"chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.titleNamed": "Volver a: {title}",
+3
View File
@@ -1521,6 +1521,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '일치하는 항목 없음', 'chat.fileMentionAutocomplete.empty': '일치하는 항목 없음',
'chat.queuedMessage.attachments': '+{count} 파일(s)', 'chat.queuedMessage.attachments': '+{count} 파일(s)',
'chat.queuedMessage.empty': '(비어 있음)', 'chat.queuedMessage.empty': '(비어 있음)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '큐에서 제거', 'chat.queuedMessage.removeAria': '큐에서 제거',
'chat.container.returnToParent.aria': '상위 세션으로 돌아가기', 'chat.container.returnToParent.aria': '상위 세션으로 돌아가기',
'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.titleNamed': '돌아가기: {title}',
+3
View File
@@ -512,6 +512,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': 'Nie znaleziono pasujących', 'chat.fileMentionAutocomplete.empty': 'Nie znaleziono pasujących',
'chat.queuedMessage.attachments': '+{count} plik(i)', 'chat.queuedMessage.attachments': '+{count} plik(i)',
'chat.queuedMessage.empty': '(puste)', 'chat.queuedMessage.empty': '(puste)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': 'Usuń z kolejki', 'chat.queuedMessage.removeAria': 'Usuń z kolejki',
'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej',
'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}',
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
"chat.fileMentionAutocomplete.empty": "Nenhuma correspondência encontrada", "chat.fileMentionAutocomplete.empty": "Nenhuma correspondência encontrada",
"chat.queuedMessage.attachments": "+{count} arquivo(s)", "chat.queuedMessage.attachments": "+{count} arquivo(s)",
"chat.queuedMessage.empty": "(vazio)", "chat.queuedMessage.empty": "(vazio)",
"chat.queuedMessage.title": "Queued messages",
"chat.queuedMessage.edit": "edit",
"chat.queuedMessage.send": "send",
"chat.queuedMessage.removeAria": "Excluir da fila", "chat.queuedMessage.removeAria": "Excluir da fila",
"chat.container.returnToParent.aria": "Voltar para a sessão principal", "chat.container.returnToParent.aria": "Voltar para a sessão principal",
"chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.titleNamed": "Voltar para: {title}",
+3
View File
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
"chat.fileMentionAutocomplete.empty": "Збігів не знайдено", "chat.fileMentionAutocomplete.empty": "Збігів не знайдено",
"chat.queuedMessage.attachments": "+{count} файл(и)", "chat.queuedMessage.attachments": "+{count} файл(и)",
"chat.queuedMessage.empty": "(порожній)", "chat.queuedMessage.empty": "(порожній)",
"chat.queuedMessage.title": "Повідомлення в черзі",
"chat.queuedMessage.edit": "edit",
"chat.queuedMessage.send": "send",
"chat.queuedMessage.removeAria": "Видалити з черги", "chat.queuedMessage.removeAria": "Видалити з черги",
"chat.container.returnToParent.aria": "Повернутися до батьківської сесії", "chat.container.returnToParent.aria": "Повернутися до батьківської сесії",
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '未找到匹配项', 'chat.fileMentionAutocomplete.empty': '未找到匹配项',
'chat.queuedMessage.attachments': '+{count} 个文件', 'chat.queuedMessage.attachments': '+{count} 个文件',
'chat.queuedMessage.empty': '(空)', 'chat.queuedMessage.empty': '(空)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '从队列移除', 'chat.queuedMessage.removeAria': '从队列移除',
'chat.container.returnToParent.aria': '返回父会话', 'chat.container.returnToParent.aria': '返回父会话',
'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.titleNamed': '返回到:{title}',
@@ -1482,6 +1482,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '找不到符合項目', 'chat.fileMentionAutocomplete.empty': '找不到符合項目',
'chat.queuedMessage.attachments': '+{count} 個檔案', 'chat.queuedMessage.attachments': '+{count} 個檔案',
'chat.queuedMessage.empty': '(空)', 'chat.queuedMessage.empty': '(空)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '從佇列移除', 'chat.queuedMessage.removeAria': '從佇列移除',
'chat.container.returnToParent.aria': '返回父會話', 'chat.container.returnToParent.aria': '返回父會話',
'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.titleNamed': '返回到:{title}',