feat(web): queue chat sends (#2870)

* feat(web): queue chat sends

* fix(web): allow queued sends from streaming composer

Keep the send button functional while a run is streaming so follow-up prompts still flow into the queue path, and cover it with a regression test.

* fix(web): polish queued send follow-ups

Keep pinned chats auto-following when the queued strip changes height, remove unused queueing scaffold, and localize the queued-send strip copy.

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
chaoxiaoche 2026-05-25 18:43:01 +08:00 committed by GitHub
parent 93b4a154cb
commit 7a70a02d83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 990 additions and 82 deletions

View file

@ -1138,7 +1138,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
reset();
return;
}
if ((!prompt && staged.length === 0 && nextCommentAttachments.length === 0) || streaming) return;
if (!prompt && staged.length === 0 && nextCommentAttachments.length === 0) return;
sendComposedTurn(prompt, staged, nextCommentAttachments, contextMeta);
}
@ -1213,6 +1213,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
.filter((s) => skillMatchesQuery(s, mentionQuery))
.sort((a, b) => skillMentionRank(a, mentionQuery) - skillMentionRank(b, mentionQuery))
: [];
const hasComposerPayload =
draft.trim().length > 0 || staged.length > 0 || currentCommentAttachments().length > 0;
const showStopButton = streaming && !hasComposerPayload;
const showSendButton = !streaming || hasComposerPayload;
return (
<div
@ -1644,7 +1648,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
</button>
{footerAccessory}
<span className="composer-spacer" />
{streaming ? (
{showStopButton ? (
<button
type="button"
className="composer-send stop"
@ -1653,7 +1657,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
<Icon name="stop" size={13} />
<span>{t('chat.stop')}</span>
</button>
) : (
) : null}
{showSendButton ? (
<button
type="button"
className="composer-send"
@ -1666,15 +1671,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
});
void submit();
}}
disabled={
sendDisabled ||
(!draft.trim() && staged.length === 0 && currentCommentAttachments().length === 0)
}
disabled={sendDisabled || !hasComposerPayload}
aria-label={t('chat.send')}
title={t('chat.send')}
>
<Icon name="send" size={13} />
<span>{t('chat.send')}</span>
</button>
)}
) : null}
</div>
</div>
{uploadError ? <span className="composer-hint">{uploadError}</span> : null}

View file

@ -222,6 +222,10 @@ interface Props {
hasActiveDesignSystem?: boolean;
activeDesignSystem?: DesignSystemSummary | null;
sendDisabled?: boolean;
queuedItems?: Array<{ id: string; prompt: string }>;
onRemoveQueuedSend?: (id: string) => void;
onUpdateQueuedSend?: (id: string, prompt: string) => void;
onSendQueuedNow?: (id: string) => void;
// Names that exist in the project folder. Tool cards and chips use this
// set to decide whether a path can be opened as a tab.
projectFileNames?: Set<string>;
@ -323,6 +327,7 @@ export function ChatPane({
messages,
streaming,
sendDisabled = false,
queuedItems = [],
error,
projectId,
projectKindForTracking = null,
@ -339,6 +344,9 @@ export function ChatPane({
onSend,
onRetry,
onStop,
onRemoveQueuedSend,
onUpdateQueuedSend,
onSendQueuedNow,
onRequestOpenFile,
onRequestPluginFolderAgentAction,
activePluginActionPaths,
@ -384,6 +392,7 @@ export function ChatPane({
const historyWrapRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<ChatComposerHandle | null>(null);
const pinnedTodoRef = useRef<HTMLDivElement | null>(null);
const queuedSendStripRef = useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = useRef(false);
// Tracks whether the user is glued close enough to the bottom that
// streamed content should auto-follow. Distinct from the jump-button
@ -649,6 +658,7 @@ export function ChatPane({
// user drifts away from the bottom. Observe the pinned-todo div so
// followLatestIfPinned fires whenever the card changes height.
let observedPinnedTodo: Element | null = null;
let observedQueuedSendStrip: Element | null = null;
const syncPinnedTodo = () => {
if (!resizeObserver) return;
const pinnedEl = pinnedTodoRef.current;
@ -661,15 +671,31 @@ export function ChatPane({
observedPinnedTodo = null;
}
};
const syncQueuedSendStrip = () => {
if (!resizeObserver) return;
const queuedEl = queuedSendStripRef.current;
if (queuedEl && observedQueuedSendStrip !== queuedEl) {
if (observedQueuedSendStrip) {
resizeObserver.unobserve(observedQueuedSendStrip);
}
resizeObserver.observe(queuedEl);
observedQueuedSendStrip = queuedEl;
} else if (!queuedEl && observedQueuedSendStrip) {
resizeObserver.unobserve(observedQueuedSendStrip);
observedQueuedSendStrip = null;
}
};
syncObservedChildren();
syncPinnedTodo();
syncQueuedSendStrip();
const mutationObserver =
typeof MutationObserver !== 'undefined'
? new MutationObserver(() => {
syncObservedChildren();
syncPinnedTodo();
syncQueuedSendStrip();
followLatestIfPinned();
})
: null;
@ -678,11 +704,11 @@ export function ChatPane({
subtree: true,
characterData: true,
});
// PinnedTodoSlot lives outside the chat-log subtree (it is a sibling of
// .chat-log-wrap inside .pane). The MutationObserver above only fires for
// changes inside el, so it cannot detect the slot mounting or unmounting.
// Watch the nearest common ancestor (.pane) with childList-only to catch
// those transitions and keep syncPinnedTodo current.
// PinnedTodoSlot and QueuedSendStrip live outside the chat-log subtree
// (they are siblings of .chat-log-wrap inside .pane). The
// MutationObserver above only fires for changes inside el, so it cannot
// detect those surfaces mounting or unmounting. Watch the nearest common
// ancestor (.pane) with childList-only to keep their observers current.
const paneEl = el.parentElement?.parentElement ?? null;
if (paneEl && mutationObserver) {
mutationObserver.observe(paneEl, { childList: true });
@ -1124,6 +1150,13 @@ export function ChatPane({
onDismiss={setDismissedPinnedTodoKey}
containerRef={pinnedTodoRef}
/>
<QueuedSendStrip
containerRef={queuedSendStripRef}
items={queuedItems}
onRemove={onRemoveQueuedSend}
onUpdate={onUpdateQueuedSend}
onSendNow={onSendQueuedNow}
/>
<ChatComposer
ref={composerRef}
projectId={projectId}
@ -1215,6 +1248,163 @@ function PinnedTodoSlot({
);
}
function QueuedSendStrip({
containerRef,
items,
onRemove,
onSendNow,
onUpdate,
}: {
containerRef?: MutableRefObject<HTMLDivElement | null>;
items: Array<{ id: string; prompt: string }>;
onRemove?: (id: string) => void;
onSendNow?: (id: string) => void;
onUpdate?: (id: string, prompt: string) => void;
}) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingDraft, setEditingDraft] = useState('');
if (items.length === 0) return null;
const visible = items.slice(0, QUEUED_SEND_VISIBLE_LIMIT);
const extra = items.length - visible.length;
const startEdit = (item: { id: string; prompt: string }) => {
setEditingId(item.id);
setEditingDraft(item.prompt);
};
const commitEdit = () => {
if (!editingId) return;
const next = editingDraft.trim();
if (next) onUpdate?.(editingId, next);
setEditingId(null);
setEditingDraft('');
};
const cancelEdit = () => {
setEditingId(null);
setEditingDraft('');
};
return (
<div
ref={containerRef}
className="chat-queued-send-strip"
data-testid="chat-queued-send-strip"
>
<div className="chat-queued-send-header">
<div className="chat-queued-send-heading">
<strong>
{items.length} {t('chat.queuedHeader')}
</strong>
<span aria-hidden></span>
<span>{t('chat.queuedToSend')}</span>
</div>
</div>
{visible.map((item, index) => (
<div
className={`chat-queued-send-row${index === 0 ? ' chat-queued-send-row-active' : ''}${
editingId === item.id ? ' chat-queued-send-row-editing' : ''
}`}
key={item.id}
>
{editingId === item.id ? (
<form
className="chat-queued-send-edit-form"
onSubmit={(event) => {
event.preventDefault();
commitEdit();
}}
>
<input
className="chat-queued-send-edit-input"
value={editingDraft}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onChange={(event) => setEditingDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
aria-label={t('chat.queuedEditQueuedTaskAria')}
/>
<button
type="submit"
className="chat-queued-send-action"
title={t('chat.queuedSave')}
aria-label={t('chat.queuedSave')}
disabled={!editingDraft.trim()}
>
<Icon name="check" size={13} />
</button>
<button
type="button"
className="chat-queued-send-action"
title={t('chat.queuedCancel')}
aria-label={t('chat.queuedCancel')}
onClick={cancelEdit}
>
<Icon name="close" size={13} />
</button>
</form>
) : (
<>
<span className="chat-queued-send-title">{summarizeQueuedPrompt(item.prompt, t)}</span>
<div className="chat-queued-send-actions">
{onUpdate ? (
<button
type="button"
className="chat-queued-send-action"
title={t('chat.queuedEdit')}
aria-label={t('chat.queuedEdit')}
onClick={() => startEdit(item)}
>
<Icon name="pencil" size={13} />
</button>
) : null}
<button
type="button"
className="chat-queued-send-action"
title={t('chat.send')}
aria-label={t('chat.send')}
onClick={() => onSendNow?.(item.id)}
disabled={!onSendNow}
>
<Icon name="arrow-up" size={13} />
</button>
{onRemove ? (
<button
type="button"
className="chat-queued-send-action"
onClick={() => onRemove(item.id)}
title={t('chat.comments.remove')}
aria-label={t('chat.comments.remove')}
>
<Icon name="trash" size={13} />
</button>
) : null}
</div>
</>
)}
</div>
))}
{extra > 0 ? (
<div className="chat-queued-send-overflow">
<span className="chat-queued-send-overflow-line" aria-hidden />
<span className="chat-queued-send-extra">+{extra}</span>
<span>{t('chat.queuedMore')}</span>
</div>
) : null}
</div>
);
}
const QUEUED_SEND_VISIBLE_LIMIT = 4;
function summarizeQueuedPrompt(prompt: string, t: TranslateFn): string {
const normalized = prompt.replace(/\s+/g, ' ').trim();
if (!normalized) return t('chat.queuedFollowUpFallback');
return normalized.length > 58 ? `${normalized.slice(0, 57)}...` : normalized;
}
function CommentsPanel({
comments,
attachedComments,

View file

@ -220,6 +220,16 @@ interface Props {
onProjectsRefresh: () => void;
}
interface QueuedChatSend {
id: string;
conversationId: string;
prompt: string;
attachments: ChatAttachment[];
commentAttachments: ChatCommentAttachment[];
meta?: ProjectChatSendMeta;
createdAt: number;
}
let liveArtifactEventSequence = 0;
const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth';
const DEFAULT_CHAT_PANEL_WIDTH = 460;
@ -636,6 +646,8 @@ export function ProjectView({
const abortRef = useRef<AbortController | null>(null);
const cancelRef = useRef<AbortController | null>(null);
const streamingConversationIdRef = useRef<string | null>(null);
const [queuedChatSends, setQueuedChatSends] = useState<QueuedChatSend[]>([]);
const queuedChatSendsRef = useRef<QueuedChatSend[]>([]);
const sendTextBufferRef = useRef<BufferedTextUpdates | null>(null);
const reattachTextBuffersRef = useRef<Set<BufferedTextUpdates>>(new Set());
const reattachControllersRef = useRef<Map<string, AbortController>>(new Map());
@ -674,6 +686,8 @@ export function ProjectView({
useEffect(() => {
setChatSeed(null);
setAutoAuditRepairSeed(null);
queuedChatSendsRef.current = [];
setQueuedChatSends([]);
}, [project.id]);
// Monotonic token bumped on every `conversation-created` refresh dispatch.
// Two rapid events (e.g. concurrent routine runs against the same reused
@ -699,10 +713,17 @@ export function ProjectView({
const currentConversationBusy = currentConversationLoading
|| currentConversationStreaming
|| currentConversationHasActiveRun;
const currentConversationAwaitingActiveRunAttach =
currentConversationHasActiveRun && !currentConversationStreaming;
const currentConversationSendDisabled = currentConversationLoading
|| currentConversationHasActiveRun
|| failedMessagesConversationId === activeConversationId;
|| failedMessagesConversationId === activeConversationId
|| currentConversationAwaitingActiveRunAttach;
const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled;
const currentConversationQueuedItems = activeConversationId
? queuedChatSends
.filter((item) => item.conversationId === activeConversationId)
.map((item) => ({ id: item.id, prompt: item.prompt }))
: [];
const newConversationDisabled = creatingConversation;
const activeCompletionNotificationRunsRef = useRef<Set<string>>(new Set());
const completedNotificationRunsRef = useRef<Set<string>>(new Set());
@ -2105,6 +2126,34 @@ export function ProjectView({
onProjectsRefresh,
]);
const enqueueChatSend = useCallback((item: QueuedChatSend) => {
const next = [...queuedChatSendsRef.current, item];
queuedChatSendsRef.current = next;
setQueuedChatSends(next);
}, []);
const removeQueuedChatSend = useCallback((id: string) => {
const next = queuedChatSendsRef.current.filter((item) => item.id !== id);
queuedChatSendsRef.current = next;
setQueuedChatSends(next);
}, []);
const updateQueuedChatSend = useCallback((id: string, prompt: string) => {
const next = queuedChatSendsRef.current.map((item) =>
item.id === id ? { ...item, prompt } : item,
);
queuedChatSendsRef.current = next;
setQueuedChatSends(next);
}, []);
const prioritizeQueuedChatSend = useCallback((id: string) => {
const item = queuedChatSendsRef.current.find((candidate) => candidate.id === id);
if (!item) return;
const next = [item, ...queuedChatSendsRef.current.filter((candidate) => candidate.id !== id)];
queuedChatSendsRef.current = next;
setQueuedChatSends(next);
}, []);
const handleSend = useCallback(
async (
prompt: string,
@ -2114,11 +2163,30 @@ export function ProjectView({
) => {
if (!activeConversationId) return;
if (messagesConversationIdRef.current !== activeConversationId) return;
if (currentConversationBusy) return;
const retryTarget = meta?.retryOfAssistantId
? resolveRetryTarget(messages, meta.retryOfAssistantId)
: null;
if (meta?.retryOfAssistantId && !retryTarget) return;
if (currentConversationBusy) {
if (meta?.retryOfAssistantId) return;
if (!prompt.trim() && attachments.length === 0 && commentAttachments.length === 0) return;
enqueueChatSend({
id: randomUUID(),
conversationId: activeConversationId,
prompt,
attachments,
commentAttachments,
...(meta === undefined ? {} : { meta }),
createdAt: Date.now(),
});
if (commentAttachments.length > 0) {
const reservedCommentIds = new Set(commentAttachments.map((attachment) => attachment.id));
setAttachedComments((current) =>
current.filter((comment) => !reservedCommentIds.has(comment.id)),
);
}
return;
}
if (
!retryTarget &&
!prompt.trim() &&
@ -2219,7 +2287,10 @@ export function ProjectView({
persistMessage(assistantMsg);
if (runCommentAttachments.length > 0) {
void patchAttachedStatuses(runCommentAttachments, 'applying');
setAttachedComments([]);
const consumedCommentIds = new Set(runCommentAttachments.map((attachment) => attachment.id));
setAttachedComments((current) =>
current.filter((comment) => !consumedCommentIds.has(comment.id)),
);
}
// If this is the first turn, derive a working title from the prompt
// so the conversation is identifiable in the dropdown without a
@ -2731,6 +2802,7 @@ export function ProjectView({
attachedComments,
activeConversationId,
currentConversationBusy,
enqueueChatSend,
messages,
config,
locale,
@ -2756,6 +2828,45 @@ export function ProjectView({
],
);
const sendQueuedChatSendNow = useCallback((id: string) => {
const item = queuedChatSendsRef.current.find((candidate) => candidate.id === id);
if (!item) return;
if (currentConversationBusy) {
prioritizeQueuedChatSend(id);
return;
}
removeQueuedChatSend(id);
void handleSend(
item.prompt,
item.attachments,
item.commentAttachments,
item.meta,
);
}, [currentConversationBusy, handleSend, prioritizeQueuedChatSend, removeQueuedChatSend]);
useEffect(() => {
if (!activeConversationId) return;
if (currentConversationBusy) return;
if (messagesConversationIdRef.current !== activeConversationId) return;
const next = queuedChatSendsRef.current.find(
(item) => item.conversationId === activeConversationId,
);
if (!next) return;
removeQueuedChatSend(next.id);
void handleSend(
next.prompt,
next.attachments,
next.commentAttachments,
next.meta,
);
}, [
activeConversationId,
currentConversationBusy,
queuedChatSends,
handleSend,
removeQueuedChatSend,
]);
const handleRetry = useCallback(
(assistantMessage: ChatMessage) => {
if (currentConversationActionDisabled) return;
@ -4135,6 +4246,7 @@ export function ProjectView({
messages={messages}
streaming={currentConversationStreaming}
sendDisabled={currentConversationSendDisabled}
queuedItems={currentConversationQueuedItems}
error={conversationLoadError ?? error ?? audioVoiceOptionsError}
projectId={project.id}
projectKindForTracking={projectKindToTracking(project.metadata?.kind)}
@ -4152,6 +4264,9 @@ export function ProjectView({
onSend={handleSend}
onRetry={handleRetry}
onStop={handleStop}
onRemoveQueuedSend={removeQueuedChatSend}
onUpdateQueuedSend={updateQueuedChatSend}
onSendQueuedNow={sendQueuedChatSendNow}
onRequestOpenFile={requestOpenFile}
onRequestPluginFolderAgentAction={handlePluginFolderAgentAction}
activePluginActionPaths={activePluginActionPaths}

View file

@ -803,6 +803,14 @@ export const ar: Dict = {
'chat.linkedFolderNotFound': 'المجلد غير موجود',
'chat.linkedFolderAlready': 'هذا المجلد مرتبط بالفعل',
'chat.linkedFolderPickError': 'تعذر فتح منتقي المجلدات',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'إرسال',
'chat.stop': 'إيقاف',
'chat.removeAria': 'إزالة {name}',

View file

@ -691,6 +691,14 @@ export const de: Dict = {
'chat.linkedFolderNotFound': 'Ordner existiert nicht',
'chat.linkedFolderAlready': 'Dieser Ordner ist bereits verknüpft',
'chat.linkedFolderPickError': 'Ordnerauswahl konnte nicht geöffnet werden',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Senden',
'chat.stop': 'Stoppen',
'chat.removeAria': '{name} entfernen',

View file

@ -1389,6 +1389,14 @@ export const en: Dict = {
'chat.linkedFolderNotFound': 'Folder does not exist',
'chat.linkedFolderAlready': 'This folder is already linked',
'chat.linkedFolderPickError': 'Could not open folder picker',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Send',
'chat.stop': 'Stop',
'chat.removeAria': 'Remove {name}',

View file

@ -692,6 +692,14 @@ export const esES: Dict = {
'chat.linkedFolderNotFound': 'La carpeta no existe',
'chat.linkedFolderAlready': 'Esta carpeta ya está vinculada',
'chat.linkedFolderPickError': 'No se pudo abrir el selector de carpetas',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Enviar',
'chat.stop': 'Detener',
'chat.removeAria': 'Quitar {name}',

View file

@ -825,6 +825,14 @@ export const fa: Dict = {
'chat.linkedFolderNotFound': 'پوشه وجود ندارد',
'chat.linkedFolderAlready': 'این پوشه قبلاً لینک شده است',
'chat.linkedFolderPickError': 'انتخابگر پوشه باز نشد',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'ارسال',
'chat.stop': 'توقف',
'chat.removeAria': 'حذف {name}',

View file

@ -819,6 +819,14 @@ export const fr: Dict = {
'chat.linkedFolderNotFound': 'Le dossier n\'existe pas',
'chat.linkedFolderAlready': 'Ce dossier est déjà lié',
'chat.linkedFolderPickError': 'Impossible d\'ouvrir le sélecteur de dossier',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Envoyer',
'chat.stop': 'Arrêter',
'chat.removeAria': 'Retirer {name}',

View file

@ -803,6 +803,14 @@ export const hu: Dict = {
'chat.linkedFolderNotFound': 'A mappa nem létezik',
'chat.linkedFolderAlready': 'Ez a mappa már hozzá van kapcsolva',
'chat.linkedFolderPickError': 'Nem sikerült megnyitni a mappaválasztót',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Küldés',
'chat.stop': 'Leállítás',
'chat.removeAria': '{name} eltávolítása',

View file

@ -916,6 +916,14 @@ export const id: Dict = {
'chat.linkedFolderNotFound': 'Folder tidak ada',
'chat.linkedFolderAlready': 'Folder ini sudah tertaut',
'chat.linkedFolderPickError': 'Tidak bisa membuka pemilih folder',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Kirim',
'chat.stop': 'Stop',
'chat.removeAria': 'Hapus {name}',

View file

@ -718,6 +718,14 @@ export const it: Dict = {
'chat.linkedFolderNotFound': 'La cartella non esiste',
'chat.linkedFolderAlready': 'Questa cartella è già collegata',
'chat.linkedFolderPickError': 'Impossibile aprire il selettore di cartelle',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Invia',
'chat.stop': 'Ferma',
'chat.removeAria': 'Rimuovi {name}',

View file

@ -690,6 +690,14 @@ export const ja: Dict = {
'chat.linkedFolderNotFound': 'フォルダーが存在しません',
'chat.linkedFolderAlready': 'このフォルダーは既にリンクされています',
'chat.linkedFolderPickError': 'フォルダー選択を開けません',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': '送信',
'chat.stop': '停止',
'chat.removeAria': '{name} を削除',

View file

@ -803,6 +803,14 @@ export const ko: Dict = {
'chat.linkedFolderNotFound': '폴더가 존재하지 않습니다',
'chat.linkedFolderAlready': '이미 연결된 폴더입니다',
'chat.linkedFolderPickError': '폴더 선택기를 열 수 없습니다',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': '전송',
'chat.stop': '중지',
'chat.removeAria': '{name} 제거',

View file

@ -803,6 +803,14 @@ export const pl: Dict = {
'chat.linkedFolderNotFound': 'Folder nie istnieje',
'chat.linkedFolderAlready': 'Ten folder jest już połączony',
'chat.linkedFolderPickError': 'Nie można otworzyć wyboru folderu',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Wyślij',
'chat.stop': 'Zatrzymaj',
'chat.removeAria': 'Usuń {name}',

View file

@ -824,6 +824,14 @@ export const ptBR: Dict = {
'chat.linkedFolderNotFound': 'A pasta não existe',
'chat.linkedFolderAlready': 'Esta pasta já está vinculada',
'chat.linkedFolderPickError': 'Não foi possível abrir o seletor de pasta',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Enviar',
'chat.stop': 'Parar',
'chat.removeAria': 'Remover {name}',

View file

@ -824,6 +824,14 @@ export const ru: Dict = {
'chat.linkedFolderNotFound': 'Папка не существует',
'chat.linkedFolderAlready': 'Эта папка уже связана',
'chat.linkedFolderPickError': 'Не удалось открыть выбор папки',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Отправить',
'chat.stop': 'Остановить',
'chat.removeAria': 'Удалить {name}',

View file

@ -758,6 +758,14 @@ export const th: Dict = {
'chat.linkedFolderNotFound': 'หาโฟลเดอร์ไม่เจอ',
'chat.linkedFolderAlready': 'ลิงก์ไว้แล้ว',
'chat.linkedFolderPickError': 'เปิดโฟลเดอร์ไม่ได้',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'ส่ง',
'chat.stop': 'หยุด',
'chat.removeAria': 'ลบ {name}',

View file

@ -792,6 +792,14 @@ export const tr: Dict = {
'chat.linkedFolderNotFound': 'Klasör mevcut değil',
'chat.linkedFolderAlready': 'Bu klasör zaten bağlantılı',
'chat.linkedFolderPickError': 'Klasör seçici açılamadı',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Gönder',
'chat.stop': 'Durdur',
'chat.removeAria': '{name}ı sil',

View file

@ -825,6 +825,14 @@ export const uk: Dict = {
'chat.linkedFolderNotFound': 'Папка не існує',
'chat.linkedFolderAlready': 'Ця папка вже пов\'язана',
'chat.linkedFolderPickError': 'Не вдалося відкрити вибір папки',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': 'Надіслати',
'chat.stop': 'Зупинити',
'chat.removeAria': 'Видалити {name}',

View file

@ -1380,6 +1380,14 @@ export const zhCN: Dict = {
'chat.linkedFolderNotFound': '文件夹不存在',
'chat.linkedFolderAlready': '该文件夹已关联',
'chat.linkedFolderPickError': '无法打开文件夹选择器',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': '发送',
'chat.stop': '停止',
'chat.removeAria': '移除 {name}',

View file

@ -994,6 +994,14 @@ export const zhTW: Dict = {
'chat.linkedFolderNotFound': '資料夾不存在',
'chat.linkedFolderAlready': '該資料夾已關聯',
'chat.linkedFolderPickError': '無法開啟資料夾選擇器',
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
'chat.send': '傳送',
'chat.stop': '停止',
'chat.removeAria': '移除 {name}',

View file

@ -1701,6 +1701,14 @@ export interface Dict {
'chat.linkedFolderNotFound': string;
'chat.linkedFolderAlready': string;
'chat.linkedFolderPickError': string;
'chat.queuedHeader': string;
'chat.queuedToSend': string;
'chat.queuedEditQueuedTaskAria': string;
'chat.queuedSave': string;
'chat.queuedCancel': string;
'chat.queuedEdit': string;
'chat.queuedMore': string;
'chat.queuedFollowUpFallback': string;
'chat.send': string;
'chat.stop': string;
'chat.removeAria': string;

View file

@ -54,6 +54,30 @@ afterEach(() => {
});
describe('ChatComposer infinite re-render regression (#2097)', () => {
it('shows only stop while streaming with an empty composer', () => {
renderComposer({ streaming: true });
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy();
expect(screen.queryByTestId('chat-send')).toBeNull();
});
it('keeps send available while streaming so the next prompt can queue', () => {
const onSend = vi.fn();
const onStop = vi.fn();
renderComposer({ streaming: true, onSend, onStop });
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
fireEvent.change(textarea, {
target: { value: 'change the font', selectionStart: 'change the font'.length },
});
expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull();
fireEvent.click(screen.getByTestId('chat-send'));
expect(onStop).not.toHaveBeenCalled();
expect(onSend).toHaveBeenCalledWith('change the font', [], [], undefined);
});
it('does not re-sync the composer scroll offset on every plain-text keystroke', () => {
const scrollTopGetter = vi.fn(() => 0);
const original = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'scrollTop');

View file

@ -62,6 +62,28 @@ describe('ChatComposer /search command', () => {
);
});
it('queues a typed follow-up when send is clicked during streaming', async () => {
const onSend = vi.fn();
render(
<ChatComposer
projectId="project-1"
projectFiles={[]}
streaming
onEnsureProject={async () => 'project-1'}
onSend={onSend}
onStop={vi.fn()}
/>,
);
fireEvent.change(screen.getByTestId('chat-composer-input'), {
target: { value: 'follow-up while busy' },
});
fireEvent.click(screen.getByTestId('chat-send'));
expect(onSend).toHaveBeenCalledWith('follow-up while busy', [], [], undefined);
});
it('auto-sends concurrent queued visual annotations when streaming ends', async () => {
const onSend = vi.fn();
const firstUpload = deferred<Awaited<ReturnType<typeof uploadProjectFiles>>>();
@ -116,11 +138,7 @@ describe('ChatComposer /search command', () => {
await Promise.all([firstUpload.promise, secondUpload.promise]);
});
await waitFor(() => expect(screen.getByText('first.png')).toBeTruthy());
expect(screen.getByText('second.png')).toBeTruthy();
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
expect(input.value).toContain('first note');
expect(input.value).toContain('second note');
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByTestId('staged-comment-attachments')).toBeNull();
rerender(
@ -135,22 +153,21 @@ describe('ChatComposer /search command', () => {
);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const [, attachments, commentAttachments] = onSend.mock.calls[0]! as [
const [prompt, attachments, commentAttachments] = onSend.mock.calls[0]! as [
string,
ChatAttachment[],
ChatCommentAttachment[],
];
expect(attachments).toEqual(expect.arrayContaining([
{ path: 'uploads/first.png', name: 'first.png', kind: 'image' },
expect(prompt).toContain('first note');
expect(prompt).toContain('second note');
expect(attachments).toEqual([
{ path: 'uploads/second.png', name: 'second.png', kind: 'image' },
]));
expect(commentAttachments).toHaveLength(2);
expect(new Set(commentAttachments.map((attachment) => attachment.id)).size).toBe(2);
expect(commentAttachments.map((attachment) => attachment.order)).toEqual([1, 2]);
expect(commentAttachments.map((attachment) => attachment.screenshotPath).sort()).toEqual([
'uploads/first.png',
'uploads/second.png',
{ path: 'uploads/first.png', name: 'first.png', kind: 'image' },
]);
expect(commentAttachments).toHaveLength(2);
expect(commentAttachments[0]?.screenshotPath).toBe('uploads/second.png');
expect(commentAttachments[1]?.screenshotPath).toBe('uploads/first.png');
expect(commentAttachments[0]?.id).not.toBe(commentAttachments[1]?.id);
});
it('sends draw annotations directly when requested', async () => {
@ -274,11 +291,8 @@ describe('ChatComposer /search command', () => {
},
}));
await waitFor(() => expect(screen.getByText('drawing.png')).toBeTruthy());
expect(screen.queryByText('Visual mark')).toBeNull();
expect(screen.getByText('drawing.png')).toBeTruthy();
expect(screen.queryByTestId('staged-comment-attachments')).toBeNull();
expect((screen.getByTestId('chat-composer-input') as HTMLTextAreaElement).value).toBe('tighten this area');
expect(onSend).not.toHaveBeenCalled();
rerender(

View file

@ -1,20 +1,31 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { forwardRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPane, retryableAssistantMessage } from '../../src/components/ChatPane';
import { DESIGN_SYSTEM_WORKSPACE_PROMPT_PREFIX } from '../../src/design-system-auto-prompt';
import type { ChatMessage, Conversation, ProjectMetadata } from '../../src/types';
const translations: Record<string, string> = {
'chat.queuedHeader': 'Queued',
'chat.queuedToSend': 'to Send',
'chat.queuedEditQueuedTaskAria': 'Edit queued task',
'chat.queuedSave': 'Save',
'chat.queuedCancel': 'Cancel',
'chat.queuedEdit': 'Edit',
'chat.queuedMore': 'more queued',
'chat.queuedFollowUpFallback': 'Queued follow-up',
};
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
t: (key: string) => translations[key] ?? key,
}),
useT: () => (key: string) => key,
useT: () => (key: string) => translations[key] ?? key,
}));
vi.mock('../../src/components/AssistantMessage', () => ({
@ -29,8 +40,47 @@ vi.mock('../../src/components/ChatComposer', () => ({
)),
}));
class MockResizeObserver {
static instances: MockResizeObserver[] = [];
callback: ResizeObserverCallback;
observed = new Set<Element>();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.instances.push(this);
}
observe = (target: Element) => {
this.observed.add(target);
};
unobserve = (target: Element) => {
this.observed.delete(target);
};
disconnect = () => {
this.observed.clear();
};
trigger(target: Element) {
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
}
beforeEach(() => {
MockResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', MockResizeObserver);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => undefined);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('ChatPane streaming state', () => {
@ -211,6 +261,138 @@ Expected output:
expect(container.querySelector('.todo-in_progress')).toBeNull();
expect(container.querySelector('.op-todo-current')).toBeNull();
});
it('shows several queued prompts above the composer before collapsing overflow', () => {
const onRemoveQueuedSend = vi.fn();
const onSendQueuedNow = vi.fn();
const onUpdateQueuedSend = vi.fn();
const { container } = render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[
{ id: 'queued-1', prompt: 'Make the export button larger and use a warmer accent' },
{ id: 'queued-2', prompt: 'Then adjust the title spacing' },
{ id: 'queued-3', prompt: 'Reduce the subtitle size' },
{ id: 'queued-4', prompt: 'Switch to a lighter font weight' },
{ id: 'queued-5', prompt: 'Add hover polish' },
]}
onRemoveQueuedSend={onRemoveQueuedSend}
onSendQueuedNow={onSendQueuedNow}
onUpdateQueuedSend={onUpdateQueuedSend}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const strip = container.querySelector('.chat-queued-send-strip');
expect(strip).not.toBeNull();
expect(strip?.textContent).toContain('5 Queued');
expect(strip?.textContent).toContain('to Send');
expect(strip?.textContent).not.toContain('Start Multitasking');
expect(container.querySelectorAll('.chat-queued-send-row')).toHaveLength(4);
expect(strip?.textContent).toContain('Make the export button larger and use a warmer accent');
expect(strip?.textContent).toContain('Then adjust the title spacing');
expect(strip?.textContent).toContain('Reduce the subtitle size');
expect(strip?.textContent).toContain('Switch to a lighter font weight');
expect(strip?.textContent).toContain('+1');
expect(container.querySelector('.chat-queued-send-overflow')?.textContent).toContain('+1');
expect(strip?.textContent).not.toContain('Add hover polish');
const sendNowButtons = screen.getAllByRole('button', { name: 'chat.send' });
fireEvent.click(sendNowButtons[1]!);
expect(onSendQueuedNow).toHaveBeenCalledWith('queued-2');
const editButtons = screen.getAllByRole('button', { name: 'Edit' });
fireEvent.click(editButtons[0]!);
const editInput = screen.getByRole('textbox', { name: 'Edit queued task' });
expect((editInput as HTMLInputElement).value).toBe(
'Make the export button larger and use a warmer accent',
);
fireEvent.change(editInput, { target: { value: 'Use a bolder export button' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onUpdateQueuedSend).toHaveBeenCalledWith('queued-1', 'Use a bolder export button');
const removeButtons = screen.getAllByRole('button', { name: 'chat.comments.remove' });
fireEvent.click(removeButtons[1]!);
expect(onRemoveQueuedSend).toHaveBeenCalledWith('queued-2');
});
it('falls back to the localized queued follow-up label for blank prompts', () => {
render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[{ id: 'queued-1', prompt: ' ' }]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByText('Queued follow-up')).toBeTruthy();
});
it('auto-follows when the queued strip resizes while pinned to bottom', () => {
const { container } = render(
<ChatPane
messages={[]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
queuedItems={[{ id: 'queued-1', prompt: 'First queued follow-up' }]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
const log = container.querySelector('.chat-log') as HTMLDivElement | null;
const strip = screen.getByTestId('chat-queued-send-strip');
expect(log).not.toBeNull();
expect(strip).toBeTruthy();
Object.defineProperty(log!, 'scrollHeight', { configurable: true, get: () => 600 });
Object.defineProperty(log!, 'clientHeight', { configurable: true, get: () => 200 });
Object.defineProperty(log!, 'scrollTop', {
configurable: true,
get() {
return (this as HTMLDivElement).dataset.scrollTop
? Number((this as HTMLDivElement).dataset.scrollTop)
: 400;
},
set(value: number) {
(this as HTMLDivElement).dataset.scrollTop = String(value);
},
});
MockResizeObserver.instances[0]?.trigger(strip);
expect(log!.scrollTop).toBe(600);
});
});
const conversations: Conversation[] = [

View file

@ -5,7 +5,13 @@ import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import type { AppConfig, ChatMessage, Conversation, Project } from '../../src/types';
import type {
AppConfig,
ChatMessage,
Conversation,
PreviewComment,
Project,
} from '../../src/types';
const listConversations = vi.fn();
const listMessages = vi.fn();
@ -124,52 +130,142 @@ vi.mock('../../src/components/Loading', () => ({
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({
activeConversationId,
conversations,
streaming,
sendDisabled,
onSelectConversation,
onSend,
onNewConversation,
error,
}: {
activeConversationId: string | null;
conversations: Conversation[];
streaming: boolean;
sendDisabled?: boolean;
error: string | null;
onSelectConversation: (id: string) => void;
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
onNewConversation: () => void;
}) => (
<section>
<output data-testid="active-conversation">{activeConversationId}</output>
<output data-testid="streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<output data-testid="chat-error">{error}</output>
{conversations.map((conversation) => (
ChatPane: ({
activeConversationId,
conversations,
streaming,
sendDisabled,
queuedItems,
previewComments,
attachedComments,
onAttachComment,
onSelectConversation,
onSend,
onSendQueuedNow,
onNewConversation,
error,
}: {
activeConversationId: string | null;
conversations: Conversation[];
streaming: boolean;
sendDisabled?: boolean;
queuedItems?: Array<{ id: string; prompt: string }>;
previewComments?: PreviewComment[];
attachedComments?: PreviewComment[];
error: string | null;
onAttachComment?: (comment: PreviewComment) => void;
onSelectConversation: (id: string) => void;
onSend: (prompt: string, attachments: unknown[], commentAttachments: unknown[]) => void;
onSendQueuedNow?: (id: string) => void;
onNewConversation: () => void;
}) => {
const attached = attachedComments ?? [];
return (
<section>
<output data-testid="active-conversation">{activeConversationId}</output>
<output data-testid="streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<output data-testid="chat-error">{error}</output>
<output data-testid="attached-comment-count">{attached.length}</output>
{queuedItems?.map((item, index) => (
<button
key={item.id}
type="button"
data-testid={`send-queued-${index}`}
onClick={() => onSendQueuedNow?.(item.id)}
>
{item.prompt}
</button>
))}
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
data-testid={`conversation-select-${conversation.id}`}
onClick={() => onSelectConversation(conversation.id)}
>
{conversation.id}
</button>
))}
<button
key={conversation.id}
type="button"
data-testid={`conversation-select-${conversation.id}`}
onClick={() => onSelectConversation(conversation.id)}
data-testid="attach-first-comment"
onClick={() => {
const first = previewComments?.[0];
if (first) onAttachComment?.(first);
}}
>
{conversation.id}
attach comment
</button>
))}
<button
type="button"
data-testid="send-message"
onClick={() => onSend('hello from b', [], [])}
disabled={sendDisabled}
>
send
</button>
<button type="button" data-testid="new-conversation" onClick={onNewConversation}>
new
</button>
</section>
),
<button
type="button"
data-testid="attach-second-comment"
onClick={() => {
const second = previewComments?.[1];
if (second) onAttachComment?.(second);
}}
>
attach second comment
</button>
<button
type="button"
data-testid="send-message"
onClick={() =>
onSend(
'hello from b',
[],
attached.map((comment, index) => ({
id: comment.id,
order: index + 1,
filePath: comment.filePath,
elementId: comment.elementId,
selector: comment.selector,
label: comment.label,
comment: comment.note,
currentText: comment.text,
pagePosition: comment.position,
htmlHint: comment.htmlHint,
selectionKind: comment.selectionKind ?? 'element',
source: 'saved-comment',
})),
)
}
disabled={sendDisabled}
>
send
</button>
<button
type="button"
data-testid="send-message-alt"
onClick={() =>
onSend(
'hello from c',
[],
attached.map((comment, index) => ({
id: comment.id,
order: index + 1,
filePath: comment.filePath,
elementId: comment.elementId,
selector: comment.selector,
label: comment.label,
comment: comment.note,
currentText: comment.text,
pagePosition: comment.position,
htmlHint: comment.htmlHint,
selectionKind: comment.selectionKind ?? 'element',
source: 'saved-comment',
})),
)
}
disabled={sendDisabled}
>
send alt
</button>
<button type="button" data-testid="new-conversation" onClick={onNewConversation}>
new
</button>
</section>
);
},
}));
const config: AppConfig = {
@ -227,6 +323,33 @@ const succeededAssistant: ChatMessage = {
endedAt: 2,
};
const previewComment: PreviewComment = {
id: 'comment-1',
projectId: project.id,
conversationId: 'conv-a',
filePath: 'index.html',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'Hero',
text: 'Hero copy',
position: { x: 1, y: 2, width: 30, height: 40 },
htmlHint: '<section data-od-id="hero">Hero copy</section>',
note: 'tighten this area',
status: 'open',
createdAt: 1,
updatedAt: 1,
};
const secondPreviewComment: PreviewComment = {
...previewComment,
id: 'comment-2',
elementId: 'cta',
selector: '[data-od-id="cta"]',
label: 'CTA',
text: 'Start now',
note: 'keep this attached',
};
describe('ProjectView conversation run isolation', () => {
let resolveConversationBMessages: ((messages: ChatMessage[]) => void) | null = null;
let conversationAMessages: ChatMessage[] = [runningAssistant];
@ -392,6 +515,102 @@ describe('ProjectView conversation run isolation', () => {
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('detaches saved comment attachments after queueing them for a busy conversation', async () => {
fetchPreviewComments.mockResolvedValue([previewComment]);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
fireEvent.click(screen.getByTestId('attach-first-comment'));
await waitFor(() => expect(screen.getByTestId('attached-comment-count').textContent).toBe('1'));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(screen.getByTestId('attached-comment-count').textContent).toBe('0'));
fireEvent.click(screen.getByTestId('send-message'));
expect(streamViaDaemon).not.toHaveBeenCalled();
expect(screen.getByTestId('attached-comment-count').textContent).toBe('0');
});
it('keeps newer attached comments when a queued send flushes older comment attachments', async () => {
let finishReattach: (() => void) | null = null;
let reattachHandlers: { onDone: () => void } | null = null;
fetchPreviewComments.mockResolvedValue([previewComment, secondPreviewComment]);
reattachDaemonRun.mockImplementation(async (input: unknown) => {
reattachHandlers = (input as { handlers: { onDone: () => void } }).handlers;
return new Promise<void>((resolve) => {
finishReattach = resolve;
});
});
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
fireEvent.click(screen.getByTestId('attach-first-comment'));
await waitFor(() => expect(screen.getByTestId('attached-comment-count').textContent).toBe('1'));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(screen.getByTestId('attached-comment-count').textContent).toBe('0'));
fireEvent.click(screen.getByTestId('attach-second-comment'));
await waitFor(() => expect(screen.getByTestId('attached-comment-count').textContent).toBe('1'));
await act(async () => {
reattachHandlers?.onDone();
finishReattach?.();
});
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(screen.getByTestId('attached-comment-count').textContent).toBe('1');
expect(streamViaDaemon).toHaveBeenCalledWith(
expect.objectContaining({
commentAttachments: [
expect.objectContaining({ id: previewComment.id }),
],
}),
);
});
it('does not overlap active runs when send-now is clicked for a queued item', async () => {
let finishReattach: (() => void) | null = null;
let reattachHandlers: { onDone: () => void } | null = null;
reattachDaemonRun.mockImplementation(async (input: unknown) => {
reattachHandlers = (input as { handlers: { onDone: () => void } }).handlers;
return new Promise<void>((resolve) => {
finishReattach = resolve;
});
});
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('streaming'));
fireEvent.click(screen.getByTestId('send-message'));
fireEvent.click(screen.getByTestId('send-message-alt'));
await waitFor(() => expect(screen.getByTestId('send-queued-1')).toBeTruthy());
fireEvent.click(screen.getByTestId('send-queued-1'));
expect(streamViaDaemon).not.toHaveBeenCalled();
await act(async () => {
reattachHandlers?.onDone();
finishReattach?.();
});
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
const payload = streamViaDaemon.mock.calls[0]?.[0] as {
history?: Array<{ role: string; content: string }>;
};
expect(payload.history?.at(-1)).toMatchObject({ role: 'user', content: 'hello from c' });
});
it('surfaces conversation message load errors and keeps sends disabled until messages load', async () => {
let conversationBLoadAttempts = 0;
listMessages.mockImplementation(async (_projectId: string, conversationId: string) => {