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 clearQueue = useMessageQueueStore((state) => state.clearQueue);
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Inline comment drafts
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.
type SubmitOptions = {
queuedOnly?: boolean;
queuedMessageId?: string;
};
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
@@ -1627,6 +1629,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, 0);
}, []);
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId });
}, []);
const handleOpenAgentPanel = React.useCallback(() => {
setMobileControlsPanel('agent');
}, []);
@@ -1645,15 +1651,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
const inputSnapshot = getCurrentInputSnapshot();
const queuedMessagesToSend = queuedMessageId
? queuedMessages.filter((message) => message.id === queuedMessageId)
: queuedMessages;
if (queuedOnly) {
if (!hasQueuedMessages || !currentSessionId) return;
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
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');
return;
}
@@ -1675,8 +1691,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const syntheticParts = consumePendingSyntheticParts();
// Process queued messages first
for (let i = 0; i < queuedMessages.length; i++) {
const queuedMsg = queuedMessages[i];
for (let i = 0; i < queuedMessagesToSend.length; i++) {
const queuedMsg = queuedMessagesToSend[i];
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
addMentionedSkills(queuedText);
@@ -1715,7 +1731,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
agentMentionName = mention.name;
}
if (queuedMessages.length === 0) {
if (queuedMessagesToSend.length === 0) {
// No queue - current input is primary
primaryText = messageText;
primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
@@ -1735,7 +1751,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
if (drafts.length > 0) {
if (queuedMessages.length === 0) {
if (queuedMessagesToSend.length === 0) {
primaryText = appendInlineComments(primaryText, drafts);
} else if (additionalParts.length > 0) {
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;
// Clear queue and input
if (currentSessionId && hasQueuedMessages) {
if (currentSessionId && queuedMessageId) {
removeFromQueue(currentSessionId, queuedMessageId);
} else if (currentSessionId && hasQueuedMessages) {
clearQueue(currentSessionId);
}
if (!queuedOnly) {
@@ -1862,13 +1880,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock });
await sendMessage(
visibleText,
currentProviderId,
currentModelId,
currentAgentName,
providerIdToSend,
modelIdToSend,
agentNameToSend,
[],
agentMentionName,
[{ text: instructionsText, synthetic: true }],
currentVariant,
variantToSend,
inputMode,
);
scrollToBottom?.();
@@ -1884,13 +1902,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const instructionsText = await renderMagicPrompt('session.review.instructions');
await sendMessage(
visibleText,
currentProviderId,
currentModelId,
currentAgentName,
providerIdToSend,
modelIdToSend,
agentNameToSend,
[],
agentMentionName,
[{ text: instructionsText, synthetic: true }],
currentVariant,
variantToSend,
inputMode,
);
scrollToBottom?.();
@@ -1933,13 +1951,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const sendPromise = sendMessage(
primaryText,
currentProviderId,
currentModelId,
currentAgentName,
providerIdToSend,
modelIdToSend,
agentNameToSend,
primaryAttachments,
agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined,
currentVariant,
variantToSend,
inputMode
);
@@ -3772,6 +3790,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
<QueuedMessageChips
onEditMessage={handleQueuedMessageEdit}
onSendMessage={handleQueuedMessageSend}
/>
{hasDrafts && (
<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 { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import { Button } from '@/components/ui/button';
interface QueuedMessageChipProps {
message: QueuedMessage;
sessionId: string;
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 removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
@@ -29,24 +31,31 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
const attachmentCount = message.attachments?.length ?? 0;
return (
<div className="flex w-full items-center gap-1.5 text-sm h-5 px-1">
<button
<div className="flex min-w-0 items-center gap-2 py-1">
<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"
variant="secondary"
size="xs"
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"
/>
<span className="text-muted-foreground flex-shrink-0">
Queued
{attachmentCount > 0 && (
<span className="ml-1">{t('chat.queuedMessage.attachments', { count: attachmentCount })}</span>
)}
</span>
<span className="text-foreground truncate">
{firstLine || t('chat.queuedMessage.empty')}
</span>
</button>
<Icon name="edit" className="h-3 w-3" aria-hidden="true" />
{t('chat.queuedMessage.edit')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onSend(message)}
>
<Icon name="send-plane" className="h-3 w-3" aria-hidden="true" />
{t('chat.queuedMessage.send')}
</Button>
<button
type="button"
onClick={() => removeFromQueue(sessionId, message.id)}
@@ -63,11 +72,13 @@ QueuedMessageChip.displayName = 'QueuedMessageChip';
interface QueuedMessageChipsProps {
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
onSendMessage: (messageId: string) => void;
}
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 queuedMessages = useMessageQueueStore(
React.useCallback(
@@ -93,20 +104,35 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
}
}, [currentSessionId, popToInput, onEditMessage]);
const handleSend = React.useCallback((message: QueuedMessage) => {
onSendMessage(message.id);
}, [onSendMessage]);
if (queuedMessages.length === 0 || !currentSessionId) {
return null;
}
return (
<div className="pb-2 w-full px-1 space-y-1">
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
onEdit={handleEdit}
/>
))}
<div className="pb-2 w-full px-1">
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
<span className="typography-ui-label font-medium text-foreground flex-shrink-0">
{t('chat.queuedMessage.title')} {queuedMessages.length}
</span>
<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>
);
});
+3
View File
@@ -1519,6 +1519,9 @@ export const dict = {
'chat.fileMentionAutocomplete.empty': 'No matches found',
'chat.queuedMessage.attachments': '+{count} file(s)',
'chat.queuedMessage.empty': '(empty)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': 'Remove from queue',
'chat.container.returnToParent.aria': 'Return to parent session',
'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.queuedMessage.attachments": "+{count} archivo(s)",
"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.container.returnToParent.aria": "Volver a la sesión principal",
"chat.container.returnToParent.titleNamed": "Volver a: {title}",
+3
View File
@@ -1521,6 +1521,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '일치하는 항목 없음',
'chat.queuedMessage.attachments': '+{count} 파일(s)',
'chat.queuedMessage.empty': '(비어 있음)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '큐에서 제거',
'chat.container.returnToParent.aria': '상위 세션으로 돌아가기',
'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.queuedMessage.attachments': '+{count} plik(i)',
'chat.queuedMessage.empty': '(puste)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': 'Usuń z kolejki',
'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej',
'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.queuedMessage.attachments": "+{count} arquivo(s)",
"chat.queuedMessage.empty": "(vazio)",
"chat.queuedMessage.title": "Queued messages",
"chat.queuedMessage.edit": "edit",
"chat.queuedMessage.send": "send",
"chat.queuedMessage.removeAria": "Excluir da fila",
"chat.container.returnToParent.aria": "Voltar para a sessão principal",
"chat.container.returnToParent.titleNamed": "Voltar para: {title}",
+3
View File
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
"chat.fileMentionAutocomplete.empty": "Збігів не знайдено",
"chat.queuedMessage.attachments": "+{count} файл(и)",
"chat.queuedMessage.empty": "(порожній)",
"chat.queuedMessage.title": "Повідомлення в черзі",
"chat.queuedMessage.edit": "edit",
"chat.queuedMessage.send": "send",
"chat.queuedMessage.removeAria": "Видалити з черги",
"chat.container.returnToParent.aria": "Повернутися до батьківської сесії",
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
@@ -1485,6 +1485,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '未找到匹配项',
'chat.queuedMessage.attachments': '+{count} 个文件',
'chat.queuedMessage.empty': '(空)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '从队列移除',
'chat.container.returnToParent.aria': '返回父会话',
'chat.container.returnToParent.titleNamed': '返回到:{title}',
@@ -1482,6 +1482,9 @@ export const dict: Record<I18nKey, string> = {
'chat.fileMentionAutocomplete.empty': '找不到符合項目',
'chat.queuedMessage.attachments': '+{count} 個檔案',
'chat.queuedMessage.empty': '(空)',
'chat.queuedMessage.title': 'Queued messages',
'chat.queuedMessage.edit': 'edit',
'chat.queuedMessage.send': 'send',
'chat.queuedMessage.removeAria': '從佇列移除',
'chat.container.returnToParent.aria': '返回父會話',
'chat.container.returnToParent.titleNamed': '返回到:{title}',