fix(web): disable Draw direct-send during an active run, keep Queue (#3270)

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.
This commit is contained in:
lefarcen 2026-05-29 13:28:18 +08:00 committed by GitHub
parent 912c7e380a
commit bf7152dbdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 72 additions and 5 deletions

View file

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

View file

@ -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',
},

View file

@ -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(
<PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running">
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.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(
<PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running">
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
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(
<PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running">
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.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(
<PreviewDrawOverlay active>