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.
This commit is contained in:
Alex committed 2026-06-25 14:18:38 +01:00
1 parent 60a06081ad
commit 7c91517d62
7 files changed
+169 -99

No files matched your search

@@ -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() {
<Sheet open={showPreview} onOpenChange={setShowPreview}>
<SheetContent
side="right"
showCloseButton={false}
className="bg-card w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
>
<WorkflowPreview
@@ -25,6 +25,7 @@ import MessageInput from '../../components/MessageInput';
import ConversationBubble from '../../conversation/ConversationBubble';
import { Query } from '../../conversation/conversationModels';
import { AppDispatch } from '../../store';
import { selectCompletedAttachments } from '../../upload/uploadSlice';
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
import WorkflowRunArtifacts from './WorkflowRunArtifacts';
import {
@@ -34,6 +35,7 @@ import {
resendQuery,
resetWorkflowPreview,
selectActiveNodeId,
setPreviewOpen,
selectWorkflowExecutionSteps,
selectWorkflowPreviewQueries,
selectWorkflowPreviewStatus,
@@ -437,6 +439,8 @@ export default function WorkflowPreview({
const status = useSelector(selectWorkflowPreviewStatus);
const executionSteps = useSelector(selectWorkflowExecutionSteps);
const activeNodeId = useSelector(selectActiveNodeId);
const completedAttachments = useSelector(selectCompletedAttachments);
const hasCompletedAttachment = completedAttachments.length > 0;
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const [openDetailsIndex, setOpenDetailsIndex] = useState<number | null>(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}
/>
</div>
</div>
@@ -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);
});
});
@@ -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<boolean>) => {
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;
+21 -16
View File
@@ -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}
>
<SendArrow
className="mx-auto my-auto block h-3.5 w-3.5 sm:h-4 sm:w-4"
+8 -1
View File
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { selectWorkflowPreviewOpen } from '../agents/workflow/workflowPreviewSlice';
import CheckCircleFilled from '../assets/check-circle-filled.svg';
import ChevronDown from '../assets/chevron-down.svg';
import WarnIcon from '../assets/warn.svg';
@@ -39,6 +40,10 @@ export default function UploadToast() {
const { t } = useTranslation();
const dispatch = useDispatch();
const uploadTasks = useSelector(selectUploadTasks);
// The workflow Preview drawer occupies the right edge; shift the toast to
// the bottom-left while it's open so it stays visible without covering the
// drawer's attach/send controls.
const previewOpen = useSelector(selectWorkflowPreviewOpen);
const visibleTasks = uploadTasks.filter((task) => !task.dismissed);
if (visibleTasks.length === 0) return null;
@@ -60,7 +65,9 @@ export default function UploadToast() {
return (
<div
className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2"
className={`fixed bottom-4 z-50 flex max-w-md flex-col gap-2 ${
previewOpen ? 'left-4' : 'right-4'
}`}
onMouseDown={(e) => e.stopPropagation()}
role="status"
aria-live="polite"
@@ -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 && (
<div className="my-2 ml-2 flex justify-start">
<Button
type="button"
onClick={() =>
onOpenArtifact({
id: primaryArtifactCall.artifact_id!,
toolName: primaryArtifactCall.tool_name,
})
}
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{!message && onOpenArtifact && completedArtifactCalls.length > 0 && (
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
{completedArtifactCalls.map((artifactCall, artifactIndex) => (
<Button
key={artifactCall.call_id ?? artifactIndex}
type="button"
onClick={() =>
onOpenArtifact({
id: artifactCall.artifact_id!,
toolName: artifactCall.tool_name,
})
}
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{primaryArtifactCall.tool_name
? formatToolName(primaryArtifactCall.tool_name)
: artifactCount > 1
? `View artifacts (${artifactCount})`
: 'View artifact'}
</Button>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{(artifactCall.tool_name
? formatToolName(artifactCall.tool_name)
: 'Artifact') +
(artifactCount > 1 ? ` (${artifactIndex + 1})` : '')}
</Button>
))}
</div>
)}
{thought && (
@@ -698,46 +698,49 @@ const ConversationBubble = forwardRef<
</div>
) : (
<>
{primaryArtifactCall?.artifact_id && onOpenArtifact && (
<div className="relative mr-2 flex items-center justify-center">
<Button
type="button"
onClick={() =>
onOpenArtifact({
id: primaryArtifactCall.artifact_id!,
toolName: primaryArtifactCall.tool_name,
})
}
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
aria-label="View artifacts"
{onOpenArtifact &&
completedArtifactCalls.map((artifactCall, artifactIndex) => (
<div
key={artifactCall.call_id ?? artifactIndex}
className="relative mr-2 flex items-center justify-center"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<Button
type="button"
onClick={() =>
onOpenArtifact({
id: artifactCall.artifact_id!,
toolName: artifactCall.tool_name,
})
}
className="h-auto rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
aria-label="View artifact"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{primaryArtifactCall.tool_name
? formatToolName(primaryArtifactCall.tool_name)
: artifactCount > 1
? `Artifacts (${artifactCount})`
: 'Artifact'}
</Button>
</div>
)}
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{(artifactCall.tool_name
? formatToolName(artifactCall.tool_name)
: 'Artifact') +
(artifactCount > 1 ? ` (${artifactIndex + 1})` : '')}
</Button>
</div>
))}
{!isStreaming && (
<>
<div className="relative mr-2 block items-center justify-center">