mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
93b4a154cb
commit
7a70a02d83
27 changed files with 990 additions and 82 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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} を削除',
|
||||
|
|
|
|||
|
|
@ -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} 제거',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue