Files
openchamber/packages/ui/src/components/chat/QueuedMessageChips.tsx
T
Bohdan Triapitsyn 07fa83cc72 feat(queue): deliver queued messages from the server
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
2026-09-04 14:08:08 +03:00

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';