From bf7152dbdc68eb4bfb1bc0d571ce4e8326aee366 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 13:28:18 +0800 Subject: [PATCH] fix(web): disable Draw direct-send during an active run, keep Queue (#3270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates the Studio tool hardening from #3081 on top of current main: while a task is streaming, the Draw/annotation primary Send action and its Enter shortcut are disabled, so an annotation can no longer leak into the active run while the button shows a disabled reason. This is the synthesis of two stacked-merge-divergent changes rather than a wholesale revert: Queue stays available, so the value from #1961 (kami) is preserved — an annotation made during a run is still staged for the next turn instead of being dropped. Only the button/Enter availability changes; the downstream queue/streaming-staging handler in ChatComposer is untouched. - PreviewDrawOverlay: send('send') and canSend now respect sendDisabled. - Reframed the streaming Draw test to assert Send is disabled while Queue still emits a queued annotation (preserving the "annotate during a run" coverage). - Added unit coverage for the Enter/Send guard and Queue availability while a task is running. --- .../web/src/components/PreviewDrawOverlay.tsx | 5 +- apps/web/tests/components/FileViewer.test.tsx | 13 ++-- .../components/PreviewDrawOverlay.test.tsx | 59 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/PreviewDrawOverlay.tsx b/apps/web/src/components/PreviewDrawOverlay.tsx index 9c8955faa8..7d3750cb6d 100644 --- a/apps/web/src/components/PreviewDrawOverlay.tsx +++ b/apps/web/src/components/PreviewDrawOverlay.tsx @@ -364,6 +364,9 @@ export function PreviewDrawOverlay({ const shouldCapture = hasInk || hasTarget || captureViewport; const canSubmit = shouldCapture || Boolean(note.trim()); if (sending || !canSubmit) return; + // While a task is running the primary Send is disabled (use Queue instead). + // The note/attachment is not lost: Queue still stages it for the next turn. + if (action === 'send' && sendDisabled) return; setCaptureWarning(null); setPendingAction(action); try { @@ -425,7 +428,7 @@ export function PreviewDrawOverlay({ const overlayPointer = active ? 'auto' : 'none'; const showCanvas = active || hasInk; const canSubmit = hasInk || Boolean(captureTarget) || captureViewport || Boolean(note.trim()); - const canSend = canSubmit; + const canSend = canSubmit && !sendDisabled; const canUndo = undoCount > 0 && !sending; const canRedo = redoCount > 0 && !sending; diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index 76f5856e29..d520c99cd6 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -1722,7 +1722,7 @@ describe('FileViewer tweaks toolbar', () => { expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc); }); - it('lets Draw direct send emit a queued annotation while a task is running', async () => { + it('disables Draw direct send during a run but keeps Queue available so the annotation is not lost', async () => { const annotationSpy = vi.fn(); window.addEventListener(ANNOTATION_EVENT, annotationSpy); @@ -1738,17 +1738,22 @@ describe('FileViewer tweaks toolbar', () => { target: { value: 'mark this' }, }); + // While a task is running the primary Send is disabled; Queue stays available + // so the annotation is staged for the next turn rather than sent mid-run. + const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; + expect(send.disabled).toBe(true); const queue = screen.getByRole('button', { name: 'Queue' }) as HTMLButtonElement; expect(queue.disabled).toBe(false); - const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; - expect(send.disabled).toBe(false); fireEvent.click(send); + expect(annotationSpy).not.toHaveBeenCalled(); + + fireEvent.click(queue); await waitFor(() => expect(annotationSpy).toHaveBeenCalledTimes(1)); expect(annotationSpy.mock.calls[0]?.[0]).toMatchObject({ detail: { - action: 'send', + action: 'queue', note: 'mark this', filePath: 'preview.html', }, diff --git a/apps/web/tests/components/PreviewDrawOverlay.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.test.tsx index 6152ddf3b8..61c1f14526 100644 --- a/apps/web/tests/components/PreviewDrawOverlay.test.tsx +++ b/apps/web/tests/components/PreviewDrawOverlay.test.tsx @@ -61,6 +61,65 @@ describe('PreviewDrawOverlay', () => { } }); + it('does not direct-send via Enter while a task is running', () => { + const annotation = vi.fn(); + window.addEventListener('opendesign:annotation', annotation); + + try { + const { container } = render( + +
+ , + ); + + const input = container.querySelector('.preview-draw-note-input'); + expect(input).toBeTruthy(); + + fireEvent.change(input!, { target: { value: 'Please inspect this panel.' } }); + fireEvent.keyDown(input!, { key: 'Enter' }); + + expect(annotation).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('opendesign:annotation', annotation); + } + }); + + it('disables the primary Send action while a task is running', () => { + const { getByRole } = render( + +
+ , + ); + + const sendButton = getByRole('button', { name: 'Send' }); + expect((sendButton as HTMLButtonElement).disabled).toBe(true); + }); + + it('keeps Queue available so an annotation is not lost while a task is running', async () => { + const annotation = vi.fn(); + window.addEventListener('opendesign:annotation', annotation); + + try { + const { container, getByRole } = render( + +
+ , + ); + + const input = container.querySelector('.preview-draw-note-input'); + fireEvent.change(input!, { target: { value: 'Queue this up.' } }); + + const queueButton = getByRole('button', { name: 'Queue' }); + expect((queueButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(queueButton); + + await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1)); + expect(annotation.mock.calls[0]?.[0].detail).toMatchObject({ action: 'queue' }); + } finally { + window.removeEventListener('opendesign:annotation', annotation); + } + }); + it('clears transient ink when draw mode exits', async () => { const { container, rerender } = render(