Messages queued while a session is busy used to live in the browser tab and were sent by that tab once the session went idle, so closing the tab (or losing the connection) stranded them. The web server now owns the queue: it persists to <data-dir>/message-queue.json, watches session.status on the global event hub, re-verifies idleness against OpenCode before sending, and delivers the head of the queue via prompt_async (or /command for slash commands) with the model, agent, variant, attachments, and agent mention captured at queue time. Failed sends stay queued and retry with backoff; a user abort holds delivery briefly; every change is broadcast so all clients see one queue. The shared UI store becomes a projection of the server queue outside VS Code (hydrate on connect, apply broadcasts, optimistic mutations settled on the server's copy, one-time upload of locally queued messages from older builds). Edit / send-now take the full message back from the server. A UI-driven auto-review run asks the server to hold that session's queue. VS Code keeps its local queue and foreground auto-send. Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
214 lines
9.0 KiB
TypeScript
214 lines
9.0 KiB
TypeScript
import React, { memo } from 'react';
|
|
import {
|
|
DndContext,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
closestCenter,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
|
|
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';
|
|
import { toast } from '@/components/ui';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface QueuedMessageChipProps {
|
|
message: QueuedMessage;
|
|
target: MessageQueueTarget;
|
|
onEdit: (message: QueuedMessage) => void;
|
|
onSend: (message: QueuedMessage) => void;
|
|
}
|
|
|
|
const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessageChipProps) => {
|
|
const { t } = useI18n();
|
|
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
|
|
|
|
// Get first line of message, truncated
|
|
const firstLine = React.useMemo(() => {
|
|
const lines = message.content.split('\n');
|
|
const first = lines[0] || '';
|
|
const maxLength = 100;
|
|
if (first.length > maxLength) {
|
|
return first.substring(0, maxLength) + '...';
|
|
}
|
|
return first + (lines.length > 1 ? '...' : '');
|
|
}, [message.content]);
|
|
|
|
const attachmentCount = message.attachments?.length ?? 0;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
// Translate only (no scaleX/scaleY) so the lifted row keeps its size.
|
|
style={{ transform: CSS.Translate.toString(transform), transition }}
|
|
className={cn('flex min-w-0 items-center gap-2 py-1', isDragging && 'z-10 opacity-60')}
|
|
>
|
|
<button
|
|
type="button"
|
|
{...attributes}
|
|
{...listeners}
|
|
className="flex flex-shrink-0 cursor-grab touch-none select-none items-center justify-center text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
|
aria-label={t('chat.queuedMessage.reorderAria')}
|
|
>
|
|
<Icon name="draggable" className="h-4 w-4" aria-hidden="true" />
|
|
</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"
|
|
variant="secondary"
|
|
size="xs"
|
|
onClick={() => onEdit(message)}
|
|
>
|
|
<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(target, message.id)}
|
|
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
|
aria-label={t('chat.queuedMessage.removeAria')}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
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, onSendMessage }: QueuedMessageChipsProps) => {
|
|
const { t } = useI18n();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
// Must use the same resolution the composer used to build the queue key —
|
|
// reading currentSessionDirectory raw can key the chips to a different
|
|
// directory than the one the messages were queued under.
|
|
const currentSessionDirectory = useSessionUIStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
|
[currentSessionId],
|
|
),
|
|
);
|
|
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
|
|
const queueKey = target ? getMessageQueueKey(target) : null;
|
|
const queuedMessages = useMessageQueueStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
if (!queueKey) return EMPTY_QUEUE;
|
|
return state.queuedMessages[queueKey] ?? EMPTY_QUEUE;
|
|
},
|
|
[queueKey]
|
|
)
|
|
);
|
|
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
|
const reorderQueue = useMessageQueueStore((state) => state.reorderQueue);
|
|
|
|
const sensors = useSensors(
|
|
// Desktop: drag after a small move so other clicks still register.
|
|
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
|
// Touch: long-press to drag (tap still hits buttons, swipe scrolls).
|
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
|
);
|
|
|
|
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id || !target) return;
|
|
reorderQueue(target, String(active.id), String(over.id));
|
|
}, [target, reorderQueue]);
|
|
|
|
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
|
if (!target) return;
|
|
|
|
// The full message (attachments included) comes back from the queue's
|
|
// owner; the chip itself only knows the summary.
|
|
void popToInput(target, message.id).then((popped) => {
|
|
if (!popped) return;
|
|
if (popped.attachments && popped.attachments.length > 0) {
|
|
const currentAttachments = useInputStore.getState().attachedFiles;
|
|
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
|
}
|
|
onEditMessage(popped.content, popped.attachments);
|
|
}).catch((error) => {
|
|
console.warn('[queue] failed to take queued message for editing:', error);
|
|
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
|
});
|
|
}, [target, popToInput, onEditMessage, t]);
|
|
|
|
const handleSend = React.useCallback((message: QueuedMessage) => {
|
|
onSendMessage(message.id);
|
|
}, [onSendMessage]);
|
|
|
|
if (queuedMessages.length === 0 || !target) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={queuedMessages.map((m) => m.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<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}
|
|
target={target}
|
|
onEdit={handleEdit}
|
|
onSend={handleSend}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
QueuedMessageChips.displayName = 'QueuedMessageChips';
|