From 7c91517d62a1d611123e5baaa802f0d78404387d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 25 Jun 2026 14:18:38 +0100 Subject: [PATCH] Fix workflow Preview run blockers and multi-artifact chips Make the doc-driven Preview flow usable and surface every produced artifact: - A Preview submit with attachments but no text now runs the workflow (opt-in on MessageInput; no filler text is injected), so a doc-driven workflow can be run by attaching its documents. - The builder's global Escape no longer tears down the whole Preview/drawer, and no longer fires from inside text fields. - The Preview drawer has a visible close button, and the upload toast moves off the attach/send controls (to the left) while the Preview is open. - A turn that produces multiple artifacts now renders one chip per artifact (labelled by tool, indexed when several), instead of exposing only the last. --- .../src/agents/workflow/WorkflowBuilder.tsx | 17 +- .../src/agents/workflow/WorkflowPreview.tsx | 17 +- .../workflow/workflowPreviewSlice.test.ts | 22 +++ .../agents/workflow/workflowPreviewSlice.ts | 11 ++ frontend/src/components/MessageInput.tsx | 37 +++-- frontend/src/components/UploadToast.tsx | 9 +- .../src/conversation/ConversationBubble.tsx | 155 +++++++++--------- 7 files changed, 169 insertions(+), 99 deletions(-) diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 0b7b7f41..5c89b557 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -725,13 +725,27 @@ function WorkflowBuilderInner() { handleDeleteNode(); } if (e.key === 'Escape') { + // While the Preview Sheet is open, leave Escape to its own dismissal + // (popovers, the Sheet itself) so a reflexive Escape can't tear down + // the node-config panel and lose the run/attachments/typed prompt. + if (showPreview) return; + // Ignore Escape originating from text fields / editable popovers so it + // dismisses the field rather than the whole config panel. + const target = e.target as HTMLElement | null; + if ( + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.isContentEditable + ) { + return; + } setShowNodeConfig(false); setSelectedNode(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedNode, handleDeleteNode]); + }, [selectedNode, handleDeleteNode, showPreview]); const handlePanelBackdropClick = useCallback(() => { setShowNodeConfig(false); @@ -2956,7 +2970,6 @@ function WorkflowBuilderInner() { 0; const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); const [openDetailsIndex, setOpenDetailsIndex] = useState(null); @@ -490,7 +494,9 @@ export default function WorkflowPreview({ index?: number; }) => { const trimmedQuestion = question.trim(); - if (trimmedQuestion === '') return; + // Doc-driven nodes read ``input_documents`` rather than the query, so an + // attachment-only run is allowed to proceed with an empty question. + if (trimmedQuestion === '' && !hasCompletedAttachment) return; if (index !== undefined) { if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion })); @@ -503,7 +509,7 @@ export default function WorkflowPreview({ handleFetchAnswer({ question: trimmedQuestion, index: undefined }); } }, - [dispatch, handleFetchAnswer], + [dispatch, handleFetchAnswer, hasCompletedAttachment], ); const handleQuestionSubmission = ( @@ -517,8 +523,8 @@ export default function WorkflowPreview({ index: indx, isRetry: false, }); - } else if (question && status !== 'loading') { - const currentInput = question.trim(); + } else if ((question || hasCompletedAttachment) && status !== 'loading') { + const currentInput = (question ?? '').trim(); if (lastQueryReturnedErr && queries.length > 0) { const lastQueryIndex = queries.length - 1; handleQuestion({ @@ -538,10 +544,12 @@ export default function WorkflowPreview({ useEffect(() => { dispatch(resetWorkflowPreview()); + dispatch(setPreviewOpen(true)); return () => { if (fetchStream.current) fetchStream.current.abort(); handleWorkflowPreviewAbort(); dispatch(resetWorkflowPreview()); + dispatch(setPreviewOpen(false)); }; }, [dispatch]); @@ -708,6 +716,7 @@ export default function WorkflowPreview({ showSourceButton={false} showToolButton={false} autoFocus={true} + allowSendWithoutText={true} /> diff --git a/frontend/src/agents/workflow/workflowPreviewSlice.test.ts b/frontend/src/agents/workflow/workflowPreviewSlice.test.ts index f7d178be..ebadefc3 100644 --- a/frontend/src/agents/workflow/workflowPreviewSlice.test.ts +++ b/frontend/src/agents/workflow/workflowPreviewSlice.test.ts @@ -4,6 +4,8 @@ import { Attachment } from '../../upload/uploadSlice'; import reducer, { addQuery, collectCompletedAttachmentIds, + resetWorkflowPreview, + setPreviewOpen, setWorkflowRunId, } from './workflowPreviewSlice'; @@ -58,3 +60,23 @@ describe('setWorkflowRunId', () => { expect(state.queries[0].workflowRunId).toBeUndefined(); }); }); + +describe('setPreviewOpen', () => { + it('defaults to closed and toggles the preview-open flag', () => { + let state = seedState(); + expect(state.previewOpen).toBe(false); + state = reducer(state, setPreviewOpen(true)); + expect(state.previewOpen).toBe(true); + state = reducer(state, setPreviewOpen(false)); + expect(state.previewOpen).toBe(false); + }); + + it('survives a resetWorkflowPreview so the overlay flag stays set', () => { + let state = seedState(); + state = reducer(state, setPreviewOpen(true)); + state = reducer(state, addQuery({ prompt: 'q' })); + state = reducer(state, resetWorkflowPreview()); + expect(state.queries).toEqual([]); + expect(state.previewOpen).toBe(true); + }); +}); diff --git a/frontend/src/agents/workflow/workflowPreviewSlice.ts b/frontend/src/agents/workflow/workflowPreviewSlice.ts index dadd48a2..061a4178 100644 --- a/frontend/src/agents/workflow/workflowPreviewSlice.ts +++ b/frontend/src/agents/workflow/workflowPreviewSlice.ts @@ -37,6 +37,9 @@ export interface WorkflowPreviewState { status: Status; executionSteps: WorkflowExecutionStep[]; activeNodeId: string | null; + // True while the Preview drawer is mounted; lets global overlays (the + // upload toast) shift off the drawer's right-side input. + previewOpen: boolean; } const initialState: WorkflowPreviewState = { @@ -44,6 +47,7 @@ const initialState: WorkflowPreviewState = { status: 'idle', executionSteps: [], activeNodeId: null, + previewOpen: false, }; let abortController: AbortController | null = null; @@ -438,6 +442,9 @@ export const workflowPreviewSlice = createSlice({ state.executionSteps = []; state.activeNodeId = null; }, + setPreviewOpen: (state, action: PayloadAction) => { + state.previewOpen = action.payload; + }, }, extraReducers(builder) { builder @@ -475,6 +482,9 @@ export const selectWorkflowExecutionSteps = ( ) => state.workflowPreview.executionSteps; export const selectActiveNodeId = (state: RootStateWithWorkflowPreview) => state.workflowPreview.activeNodeId; +export const selectWorkflowPreviewOpen = ( + state: RootStateWithWorkflowPreview, +) => state.workflowPreview.previewOpen; export const { addQuery, @@ -491,6 +501,7 @@ export const { raiseError, resetWorkflowPreview, clearExecutionSteps, + setPreviewOpen, } = workflowPreviewSlice.actions; export default workflowPreviewSlice.reducer; diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 76f77b68..f44797c5 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -293,6 +293,10 @@ type MessageInputProps = { showSourceButton?: boolean; showToolButton?: boolean; autoFocus?: boolean; + // Opt-in: enable send with empty text when there are completed + // attachments (used by doc-driven workflow runs). Normal chat leaves this + // unset, preserving the text-required behavior. + allowSendWithoutText?: boolean; }; export default function MessageInput({ @@ -301,6 +305,7 @@ export default function MessageInput({ showSourceButton = true, showToolButton = true, autoFocus = true, + allowSendWithoutText = false, }: MessageInputProps) { const { t } = useTranslation(); const [value, setValue] = useState(''); @@ -1467,13 +1472,21 @@ export default function MessageInput({ setIsSourcesPopupOpen(false); }; + // When ``allowSendWithoutText`` is set, an attachment-only submit is + // permitted as long as at least one attachment has finished processing. + const hasCompletedAttachment = attachments.some( + (attachment) => attachment.status === 'completed', + ); + const hasSubmittableContent = + Boolean(value.trim()) || (allowSendWithoutText && hasCompletedAttachment); + const canSubmit = + hasSubmittableContent && + !loading && + recordingState !== 'recording' && + recordingState !== 'transcribing'; + const handleSubmit = () => { - if ( - value.trim() && - !loading && - recordingState !== 'recording' && - recordingState !== 'transcribing' - ) { + if (canSubmit) { onSubmit(value); setValue(''); if (isTouch) { @@ -1635,19 +1648,11 @@ export default function MessageInput({ onClick={handleSubmit} aria-label={t('send')} className={`ml-auto h-7 w-7 shrink-0 rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${ - value.trim() && - !loading && - recordingState !== 'recording' && - recordingState !== 'transcribing' + canSubmit ? 'bg-primary text-white' : 'bg-muted text-muted-foreground dark:bg-accent dark:text-muted-foreground' }`} - disabled={ - !value.trim() || - loading || - recordingState === 'recording' || - recordingState === 'transcribing' - } + disabled={!canSubmit} > !task.dismissed); if (visibleTasks.length === 0) return null; @@ -60,7 +65,9 @@ export default function UploadToast() { return (
e.stopPropagation()} role="status" aria-live="polite" diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 32d69f89..2a5ccd66 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -127,8 +127,6 @@ const ConversationBubble = forwardRef< const completedArtifactCalls = (toolCalls ?? []).filter( (toolCall) => toolCall.artifact_id && toolCall.status === 'completed', ); - const primaryArtifactCall = - completedArtifactCalls[completedArtifactCalls.length - 1] ?? null; const artifactCount = completedArtifactCalls.length; const formatToolName = (toolName: string | undefined): string => { @@ -447,43 +445,45 @@ const ConversationBubble = forwardRef< agentId={agentId} /> )} - {!message && primaryArtifactCall?.artifact_id && onOpenArtifact && ( -
- + + + + + {(artifactCall.tool_name + ? formatToolName(artifactCall.tool_name) + : 'Artifact') + + (artifactCount > 1 ? ` (${artifactIndex + 1})` : '')} + + ))}
)} {thought && ( @@ -698,46 +698,49 @@ const ConversationBubble = forwardRef<
) : ( <> - {primaryArtifactCall?.artifact_id && onOpenArtifact && ( -
- -
- )} + + + + + {(artifactCall.tool_name + ? formatToolName(artifactCall.tool_name) + : 'Artifact') + + (artifactCount > 1 ? ` (${artifactIndex + 1})` : '')} + + + ))} {!isStreaming && ( <>