From 01ee877eb02742ff3caaa0a73231d68db5a328b4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 21:40:11 +0800 Subject: [PATCH] fix(web): queue preview comments during runs Keep preview comment sends available while an agent run is streaming by queueing notes and forwarding them once the run can accept attachments. Agent-Model: gpt-5 Agent-Family: openai Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197 Agent-Step: 0.0.5 --- apps/web/src/components/FileViewer.tsx | 21 +++-- apps/web/src/components/FileWorkspace.tsx | 5 +- apps/web/src/components/ProjectView.tsx | 8 +- apps/web/tests/components/FileViewer.test.tsx | 82 ++++++++++++++++++ .../ProjectView.run-cleanup.test.tsx | 85 ++++++++++++++++++- 5 files changed, 190 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index ddc2d2b07..fd7f1d4a9 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -684,10 +684,11 @@ interface Props { isDeck?: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; + commentSendDisabled?: boolean; previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; - onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | void; + onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | boolean | void; onFileSaved?: () => Promise | void; // Open `openName` as a tab (focusing it) and close `closeName` in one // atomic tab-state update. The React module pointer uses this to jump to the @@ -706,6 +707,7 @@ export function FileViewer({ isDeck, onExportAsPptx, streaming, + commentSendDisabled = false, previewComments = [], onSavePreviewComment, onRemovePreviewComment, @@ -746,6 +748,7 @@ export function FileViewer({ isDeck={rendererMatch.renderer.id === 'deck-html'} onExportAsPptx={onExportAsPptx} streaming={Boolean(streaming)} + commentSendDisabled={commentSendDisabled} previewComments={previewComments} onSavePreviewComment={onSavePreviewComment} onRemovePreviewComment={onRemovePreviewComment} @@ -3841,6 +3844,7 @@ function HtmlViewer({ isDeck, onExportAsPptx, streaming, + commentSendDisabled, previewComments = [], onSavePreviewComment, onRemovePreviewComment, @@ -3857,10 +3861,11 @@ function HtmlViewer({ isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming: boolean; + commentSendDisabled: boolean; previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; - onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | void; + onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | boolean | void; onFileSaved?: () => Promise | void; commentPortalId?: string; onCommentModeChange?: (active: boolean) => void; @@ -6129,12 +6134,13 @@ const [manualEditTargets, setManualEditTargets] = useState([ if (nextNotes.length === 0) return; setSendingBoardBatch(true); try { - await onSendBoardCommentAttachments( + const accepted = await onSendBoardCommentAttachments( buildBoardCommentAttachments({ target: targetFromSnapshot(activeCommentTarget), notes: nextNotes, }), ); + if (accepted === false) return; clearBoardComposer(); } finally { setSendingBoardBatch(false); @@ -6429,7 +6435,8 @@ const [manualEditTargets, setManualEditTargets] = useState([ }); setActivePreviewCommentId((current) => (current === commentId ? null : current)); } : undefined} - sending={sendingBoardBatch || streaming} + sending={sendingBoardBatch || commentSendDisabled} + t={t} scale={overlayPreviewScale} offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }} @@ -6500,14 +6507,14 @@ const [manualEditTargets, setManualEditTargets] = useState([ fireCommentPopoverClick('send_to_chat'); setSendingBoardBatch(true); try { - await onSendBoardCommentAttachments(commentsToAttachments(selected)); - setSelectedSideCommentIds(new Set()); + const accepted = await onSendBoardCommentAttachments(commentsToAttachments(selected)); + if (accepted !== false) setSelectedSideCommentIds(new Set()); } finally { setSendingBoardBatch(false); } }} onCreateComment={savePanelComment} - sending={sendingBoardBatch || streaming} + sending={sendingBoardBatch || commentSendDisabled} t={t} composer={null} /> diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 5dfdd6b1f..e2f12278a 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -69,6 +69,7 @@ interface Props { isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; + commentSendDisabled?: boolean; openRequest?: { name: string; nonce: number } | null; liveArtifactEvents?: LiveArtifactEventItem[]; designSystemActivityEvents?: AgentEvent[]; @@ -79,7 +80,7 @@ interface Props { previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; - onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | void; + onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | boolean | void; onPluginFolderAgentAction?: ( relativePath: string, action: PluginFolderAgentAction, @@ -200,6 +201,7 @@ export function FileWorkspace({ isDeck, onExportAsPptx, streaming, + commentSendDisabled = false, openRequest, liveArtifactEvents = [], designSystemActivityEvents = [], @@ -1063,6 +1065,7 @@ export function FileWorkspace({ isDeck={isDeck} onExportAsPptx={onExportAsPptx} streaming={streaming} + commentSendDisabled={commentSendDisabled} previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)} onSavePreviewComment={onSavePreviewComment} onRemovePreviewComment={onRemovePreviewComment} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 02aaa4e62..7cc054071 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -741,6 +741,8 @@ export function ProjectView({ || failedMessagesConversationId === activeConversationId || currentConversationAwaitingActiveRunAttach; const currentConversationActionDisabled = currentConversationBusy || currentConversationSendDisabled; + const currentConversationQueueDisabled = currentConversationLoading + || failedMessagesConversationId === activeConversationId; const currentConversationQueuedItems = activeConversationId ? queuedChatSends .filter((item) => item.conversationId === activeConversationId) @@ -3060,12 +3062,13 @@ export function ProjectView({ const handleSendBoardCommentAttachments = useCallback( async (commentAttachments: ChatCommentAttachment[]) => { - if (currentConversationActionDisabled || commentAttachments.length === 0) return; + if (currentConversationQueueDisabled || commentAttachments.length === 0) return false; setWorkspaceFocused(false); setCommentInspectorActive(false); await handleSend('', [], commentAttachments); + return true; }, - [handleSend, currentConversationActionDisabled], + [handleSend, currentConversationQueueDisabled], ); const handleContinueRemainingTasks = useCallback( @@ -4552,6 +4555,7 @@ export function ProjectView({ isDeck={isDeck} onExportAsPptx={handleExportAsPptx} streaming={currentConversationActionDisabled} + commentSendDisabled={currentConversationQueueDisabled} openRequest={openRequest} liveArtifactEvents={liveArtifactEvents} designSystemActivityEvents={designSystemActivityEvents} diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index b9e62e426..b0e42d69b 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -2338,6 +2338,88 @@ describe('FileViewer tweaks toolbar', () => { expect(activeItem?.getAttribute('aria-current')).toBe('true'); }); + it('lets element comments queue to chat while a task is running', async () => { + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + fireEvent.click(screen.getByTestId('board-mode-toggle')); + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od:comment-target', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + position: { x: 8, y: 12, width: 120, height: 48 }, + htmlHint: '
Hero
', + }, + })); + + const input = await screen.findByTestId('comment-popover-input'); + fireEvent.change(input, { target: { value: '加大字号' } }); + const send = screen.getByTestId('comment-add-send') as HTMLButtonElement; + expect(send.disabled).toBe(false); + + fireEvent.click(send); + + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1)); + expect(onSendBoardCommentAttachments.mock.calls[0]?.[0]?.[0]).toMatchObject({ + filePath: 'preview.html', + elementId: 'hero', + comment: '加大字号', + source: 'board-batch', + }); + await waitFor(() => expect(screen.queryByTestId('comment-popover')).toBeNull()); + }); + + it('keeps the comment draft when chat queueing declines the send', async () => { + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(false); + render( + , + ); + + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + fireEvent.click(screen.getByTestId('board-mode-toggle')); + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od:comment-target', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + position: { x: 8, y: 12, width: 120, height: 48 }, + htmlHint: '
Hero
', + }, + })); + + const input = await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement; + fireEvent.change(input, { target: { value: '保留这个评论' } }); + fireEvent.click(screen.getByTestId('comment-add-send')); + + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('comment-popover')).toBeTruthy(); + expect((screen.getByTestId('comment-popover-input') as HTMLTextAreaElement).value) + .toBe('保留这个评论'); + }); + it('returns to element picking from the Comment button while another annotation tool is active', async () => { render( ({ }, })); +const fileWorkspaceSpy = vi.fn(); vi.mock('../../src/components/FileWorkspace', () => ({ - FileWorkspace: () => null, + FileWorkspace: (props: Record) => { + fileWorkspaceSpy(props); + return null; + }, })); vi.mock('../../src/components/Loading', () => ({ @@ -702,6 +706,85 @@ describe('ProjectView daemon cleanup', () => { } }); + it('queues board comment attachments while the current daemon run is still busy', async () => { + listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]); + listMessages.mockResolvedValue([]); + fetchPreviewComments.mockResolvedValue([]); + loadTabs.mockResolvedValue({ tabs: [], activeTabId: null }); + fetchProjectFiles.mockResolvedValue([]); + fetchLiveArtifacts.mockResolvedValue([]); + fetchSkill.mockResolvedValue(null); + fetchDesignSystem.mockResolvedValue(null); + getTemplate.mockResolvedValue(null); + listActiveChatRuns.mockResolvedValue([]); + streamViaDaemon.mockImplementation(async () => new Promise(() => {})); + + render( + {}} + onAgentChange={() => {}} + onAgentModelChange={() => {}} + onRefreshAgents={() => {}} + onOpenSettings={() => {}} + onBack={() => {}} + onClearPendingPrompt={() => {}} + onTouchProject={() => {}} + onProjectChange={() => {}} + onProjectsRefresh={() => {}} + />, + ); + + const chatProps = await waitForReadyChatPaneProps(); + await chatProps.onSend!('keep running', [], []); + await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(true); + }); + + const workspaceProps = fileWorkspaceSpy.mock.calls.at(-1)?.[0] as { + onSendBoardCommentAttachments?: (attachments: unknown[]) => Promise; + }; + expect(workspaceProps.onSendBoardCommentAttachments).toBeTruthy(); + + await workspaceProps.onSendBoardCommentAttachments!([ + { + id: 'hero-board-1', + order: 1, + filePath: 'preview.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + comment: 'Use a warmer accent', + currentText: 'Hero', + pagePosition: { x: 10, y: 20, width: 100, height: 40 }, + htmlHint: '
', + source: 'board-batch', + }, + ]); + + await waitFor(() => { + const latest = chatPaneSpy.mock.calls.at(-1)?.[0] as { + queuedItems?: Array<{ commentAttachments?: Array<{ comment: string }> }>; + }; + expect(latest.queuedItems).toHaveLength(1); + expect(latest.queuedItems?.[0]?.commentAttachments?.[0]?.comment).toBe('Use a warmer accent'); + }); + expect(streamViaDaemon).toHaveBeenCalledTimes(1); + }); + it('audits design-system workspace output after first auto-send and seeds a bounded repair prompt', async () => { listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]); listMessages.mockResolvedValue([]);