From c0f361bb6ed72db5278f28802b73493c23fc4712 Mon Sep 17 00:00:00 2001 From: Stanislav Novosad Date: Tue, 27 Jan 2026 13:24:44 -0700 Subject: [PATCH] Workflow Copilot: review and approve/reject changes (#4559) --- ...workflow_copilot_chat_proposed_workflow.py | 33 ++++ .../workflows/copilot/WorkflowCopilotChat.tsx | 158 +++++++++++++-- .../workflows/copilot/workflowCopilotTypes.ts | 7 + .../src/routes/workflows/editor/Workspace.tsx | 185 ++++++++++++++---- .../editor/panels/WorkflowComparisonPanel.tsx | 98 ++++++++-- .../src/store/WorkflowPanelStore.ts | 3 + skyvern/forge/sdk/db/agent_db.py | 28 +++ skyvern/forge/sdk/db/models.py | 2 + skyvern/forge/sdk/routes/workflow_copilot.py | 38 +++- skyvern/forge/sdk/schemas/workflow_copilot.py | 9 + 10 files changed, 481 insertions(+), 80 deletions(-) create mode 100644 alembic/versions/2026_01_27_2020-43217e31df12_workflow_copilot_chat_proposed_workflow.py diff --git a/alembic/versions/2026_01_27_2020-43217e31df12_workflow_copilot_chat_proposed_workflow.py b/alembic/versions/2026_01_27_2020-43217e31df12_workflow_copilot_chat_proposed_workflow.py new file mode 100644 index 00000000..5e6fc809 --- /dev/null +++ b/alembic/versions/2026_01_27_2020-43217e31df12_workflow_copilot_chat_proposed_workflow.py @@ -0,0 +1,33 @@ +"""workflow copilot chat proposed workflow + +Revision ID: 43217e31df12 +Revises: ce791d022652 +Create Date: 2026-01-27 20:20:36.952383+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "43217e31df12" +down_revision: Union[str, None] = "ce791d022652" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("workflow_copilot_chats", sa.Column("proposed_workflow", sa.JSON(), nullable=True)) + op.add_column("workflow_copilot_chats", sa.Column("auto_accept", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("workflow_copilot_chats", "auto_accept") + op.drop_column("workflow_copilot_chats", "proposed_workflow") + # ### end Alembic commands ### diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx index dbcf96d8..064fb59f 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx +++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx @@ -16,6 +16,7 @@ import { WorkflowCopilotStreamResponseUpdate, WorkflowCopilotChatSender, WorkflowCopilotChatRequest, + WorkflowCopilotClearProposedWorkflowRequest, } from "./workflowCopilotTypes"; interface ChatMessage { @@ -41,7 +42,12 @@ const formatChatTimestamp = (value: string) => { }); }; -const MessageItem = memo(({ message }: { message: ChatMessage }) => { +interface MessageItemProps { + message: ChatMessage; + footer?: React.ReactNode; +} + +const MessageItem = memo(({ message, footer }: MessageItemProps) => { return (
{

{message.content}

+ {footer ?
{footer}
: null} {message.timestamp ? ( {formatChatTimestamp(message.timestamp)} @@ -67,6 +74,10 @@ const MessageItem = memo(({ message }: { message: ChatMessage }) => { interface WorkflowCopilotChatProps { onWorkflowUpdate?: (workflow: WorkflowApiResponse) => void; + onReviewWorkflow?: ( + workflow: WorkflowApiResponse, + clearPending: () => void, + ) => void; isOpen?: boolean; onClose?: () => void; onMessageCountChange?: (count: number) => void; @@ -117,12 +128,16 @@ const constrainPosition = ( export function WorkflowCopilotChat({ onWorkflowUpdate, + onReviewWorkflow, isOpen = true, onClose, onMessageCountChange, buttonRef, }: WorkflowCopilotChatProps = {}) { const [messages, setMessages] = useState([]); + const [proposedWorkflow, setProposedWorkflow] = + useState(null); + const [autoAccept, setAutoAccept] = useState(false); const [inputValue, setInputValue] = useState(""); const [isLoading, setIsLoading] = useState(false); const [processingStatus, setProcessingStatus] = useState(""); @@ -184,9 +199,74 @@ export function WorkflowCopilotChat({ const handleNewChat = () => { setMessages([]); setWorkflowCopilotChatId(null); + setProposedWorkflow(null); + setAutoAccept(false); hasScrolledOnLoad.current = false; }; + const applyWorkflowUpdate = (workflow: WorkflowApiResponse): boolean => { + if (!onWorkflowUpdate) { + return true; + } + try { + onWorkflowUpdate(workflow); + return true; + } catch (updateError) { + console.error("Failed to update workflow:", updateError); + toast({ + title: "Update failed", + description: "Failed to apply workflow changes. Please try again.", + variant: "destructive", + }); + return false; + } + }; + + const handleAcceptWorkflow = ( + workflow: WorkflowApiResponse, + alwaysAccept: boolean = false, + ) => { + if (!applyWorkflowUpdate(workflow)) { + return; + } + setProposedWorkflow(null); + if (alwaysAccept) { + setAutoAccept(true); + } + void clearProposedWorkflow(alwaysAccept); + }; + + const handleRejectWorkflow = () => { + setProposedWorkflow(null); + void clearProposedWorkflow(false); + }; + + const clearProposedWorkflow = async (autoAcceptValue: boolean) => { + try { + const client = await getClient(credentialGetter, "sans-api-v1"); + await client.post( + "/workflow/copilot/clear-proposed-workflow", + { + workflow_copilot_chat_id: workflowCopilotChatId ?? "", + auto_accept: autoAcceptValue, + } as WorkflowCopilotClearProposedWorkflowRequest, + ); + } catch (error) { + console.error("Failed to clear proposed workflow:", error); + toast({ + title: "Copilot update failed", + description: autoAcceptValue + ? "Workflow was applied, but auto-accept did not update." + : "Failed to clear copilot proposal. Please try again.", + variant: "destructive", + }); + } + }; + + const handleReviewWorkflow = (workflow: WorkflowApiResponse) => { + onReviewWorkflow?.(workflow, () => setProposedWorkflow(null)); + }; + // Notify parent of message count changes useEffect(() => { if (onMessageCountChange) { @@ -214,6 +294,8 @@ export function WorkflowCopilotChat({ if (!workflowPermanentId) { setMessages([]); setWorkflowCopilotChatId(null); + setProposedWorkflow(null); + setAutoAccept(false); return; } @@ -243,6 +325,8 @@ export function WorkflowCopilotChat({ ); setMessages(historyMessages); setWorkflowCopilotChatId(response.data.workflow_copilot_chat_id); + setProposedWorkflow(response.data.proposed_workflow ?? null); + setAutoAccept(response.data.auto_accept ?? false); } catch (error) { console.error("Failed to load chat history:", error); } finally { @@ -306,6 +390,7 @@ export function WorkflowCopilotChat({ pendingMessageId.current = userMessageId; setMessages((prev) => [...prev, userMessage]); + setProposedWorkflow(null); const messageContent = inputValue; setInputValue(""); setIsLoading(true); @@ -398,10 +483,7 @@ export function WorkflowCopilotChat({ setMessages((prev) => prev.map((message) => message.id === pendingId - ? { - ...message, - timestamp: payload.timestamp, - } + ? { ...message, timestamp: payload.timestamp } : message, ), ); @@ -420,19 +502,10 @@ export function WorkflowCopilotChat({ }; setMessages((prev) => [...prev, aiMessage]); - - if (response.updated_workflow && onWorkflowUpdate) { - try { - onWorkflowUpdate(response.updated_workflow); - } catch (updateError) { - console.error("Failed to update workflow:", updateError); - toast({ - title: "Update failed", - description: - "Failed to apply workflow changes. Please try again.", - variant: "destructive", - }); - } + if (response.updated_workflow && autoAccept) { + applyWorkflowUpdate(response.updated_workflow); + } else { + setProposedWorkflow(response.updated_workflow ?? null); } }; @@ -708,9 +781,52 @@ export function WorkflowCopilotChat({

) : null} - {messages.map((message) => ( - - ))} + {messages.map((message, index) => { + const isLastMessage = index === messages.length - 1; + const showProposedPanel = isLastMessage && proposedWorkflow; + return ( + + + + + + + ) : null + } + /> + ); + })} {isLoading && (
diff --git a/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts b/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts index cce6ab72..e4fe3b90 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts +++ b/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts @@ -38,6 +38,13 @@ export interface WorkflowCopilotChatHistoryMessage { export interface WorkflowCopilotChatHistoryResponse { workflow_copilot_chat_id: string | null; chat_history: WorkflowCopilotChatHistoryMessage[]; + proposed_workflow?: WorkflowApiResponse | null; + auto_accept?: boolean | null; +} + +export interface WorkflowCopilotClearProposedWorkflowRequest { + workflow_copilot_chat_id: string; + auto_accept: boolean; } export type WorkflowCopilotStreamMessageType = diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 9f79e252..27d76b3d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -94,7 +94,11 @@ import { import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel"; import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery"; -import { WorkflowSettings } from "../types/workflowTypes"; +import { + Parameter, + WorkflowBlock, + WorkflowSettings, +} from "../types/workflowTypes"; import { ProxyLocation } from "@/api/types"; import { nodeAdderNode, @@ -107,6 +111,7 @@ import { import { constructCacheKeyValue, getInitialParameters } from "./utils"; import { WorkflowCopilotChat } from "../copilot/WorkflowCopilotChat"; import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton"; +import type { CopilotReviewStatus } from "./panels/WorkflowComparisonPanel"; import "./workspace-styles.css"; const Constants = { @@ -981,6 +986,40 @@ function Workspace({ } }; + const applyWorkflowUpdate = (workflowData: WorkflowVersion) => { + const settings: WorkflowSettings = { + proxyLocation: workflowData.proxy_location ?? ProxyLocation.Residential, + webhookCallbackUrl: workflowData.webhook_callback_url || "", + persistBrowserSession: workflowData.persist_browser_session ?? false, + model: workflowData.model ?? null, + maxScreenshotScrolls: workflowData.max_screenshot_scrolls || 3, + extraHttpHeaders: workflowData.extra_http_headers + ? JSON.stringify(workflowData.extra_http_headers) + : null, + runWith: workflowData.run_with ?? null, + scriptCacheKey: workflowData.cache_key ?? null, + aiFallback: workflowData.ai_fallback ?? true, + runSequentially: workflowData.run_sequentially ?? false, + sequentialKey: workflowData.sequential_key ?? null, + finallyBlockLabel: + workflowData.workflow_definition?.finally_block_label ?? null, + }; + + const elements = getElements( + workflowData.workflow_definition.blocks, + settings, + true, + ); + + setNodes(elements.nodes); + setEdges(elements.edges); + + const initialParameters = getInitialParameters(workflowData); + useWorkflowParametersStore.getState().setParameters(initialParameters); + + workflowChangesStore.setHasChanges(true); + }; + const handleSelectState = (selectedVersion: WorkflowVersion) => { // Close panels setWorkflowPanelState({ @@ -1239,7 +1278,9 @@ function Workspace({
@@ -1248,6 +1289,10 @@ function Workspace({ version1={workflowPanelState.data.version1} version2={workflowPanelState.data.version2} onSelectState={handleSelectState} + mode={workflowPanelState.data.mode} + onCopilotReviewClose={ + workflowPanelState.data.onCopilotReviewClose + } />
@@ -1698,43 +1743,109 @@ function Workspace({ onClose={() => setIsCopilotOpen(false)} onMessageCountChange={setCopilotMessageCount} buttonRef={copilotButtonRef} + onReviewWorkflow={(pendingWorkflow, clearPending) => { + const saveData = workflowChangesStore.getSaveData?.(); + if (!saveData) return; + + // Construct fake WorkflowVersion for current state + const currentVersion: WorkflowVersion = { + workflow_id: saveData.workflow.workflow_id, + organization_id: "", + is_saved_task: saveData.workflow.is_saved_task ?? false, + is_template: false, + title: "Current", + workflow_permanent_id: saveData.workflow.workflow_permanent_id, + version: saveData.workflow.version ?? 0, + description: saveData.workflow.description ?? "", + workflow_definition: { + parameters: saveData.parameters as unknown as Parameter[], + blocks: saveData.blocks as unknown as WorkflowBlock[], + finally_block_label: saveData.settings.finallyBlockLabel, + }, + proxy_location: saveData.settings.proxyLocation, + webhook_callback_url: saveData.settings.webhookCallbackUrl, + extra_http_headers: saveData.settings.extraHttpHeaders + ? JSON.parse(saveData.settings.extraHttpHeaders) + : null, + persist_browser_session: saveData.settings.persistBrowserSession, + model: saveData.settings.model, + totp_verification_url: saveData.workflow.totp_verification_url, + totp_identifier: null, + max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls, + status: saveData.workflow.status, + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + deleted_at: null, + run_with: saveData.settings.runWith, + cache_key: saveData.settings.scriptCacheKey, + ai_fallback: saveData.settings.aiFallback, + run_sequentially: saveData.settings.runSequentially, + sequential_key: saveData.settings.sequentialKey, + folder_id: null, + import_error: null, + }; + + // Construct fake WorkflowVersion for pending copilot suggestion + const pendingVersion: WorkflowVersion = { + ...pendingWorkflow, + title: "Copilot Suggestion", + }; + + // Handle copilot review close with status + const handleCopilotReviewClose = (status: CopilotReviewStatus) => { + if (status === "approve") { + try { + applyWorkflowUpdate(pendingWorkflow); + } catch (error) { + console.error( + "Failed to apply copilot workflow", + error, + pendingWorkflow, + ); + toast({ + title: "Update failed", + description: + "Failed to apply workflow update. Please try again.", + variant: "destructive", + }); + } + } + + // Close the panel and reopen copilot chat + setWorkflowPanelState({ + active: false, + content: "history", + data: { + showComparison: false, + version1: undefined, + version2: undefined, + }, + }); + setIsCopilotOpen(true); + + // Clear pending for approve and reject, but not for close + if (status !== "close") { + clearPending(); + } + }; + + // Hide chat and show comparison + setIsCopilotOpen(false); + setWorkflowPanelState({ + active: false, + content: "history", + data: { + version1: currentVersion, + version2: pendingVersion, + showComparison: true, + mode: "copilot", + onCopilotReviewClose: handleCopilotReviewClose, + }, + }); + }} onWorkflowUpdate={(workflowData) => { try { - const settings: WorkflowSettings = { - proxyLocation: - workflowData.proxy_location ?? ProxyLocation.Residential, - webhookCallbackUrl: workflowData.webhook_callback_url || "", - persistBrowserSession: - workflowData.persist_browser_session ?? false, - model: workflowData.model ?? null, - maxScreenshotScrolls: workflowData.max_screenshot_scrolls || 3, - extraHttpHeaders: workflowData.extra_http_headers - ? JSON.stringify(workflowData.extra_http_headers) - : null, - runWith: workflowData.run_with ?? null, - scriptCacheKey: workflowData.cache_key ?? null, - aiFallback: workflowData.ai_fallback ?? true, - runSequentially: workflowData.run_sequentially ?? false, - sequentialKey: workflowData.sequential_key ?? null, - finallyBlockLabel: - workflowData.workflow_definition?.finally_block_label ?? null, - }; - - const elements = getElements( - workflowData.workflow_definition.blocks, - settings, - true, - ); - - setNodes(elements.nodes); - setEdges(elements.edges); - - const initialParameters = getInitialParameters(workflowData); - useWorkflowParametersStore - .getState() - .setParameters(initialParameters); - - workflowChangesStore.setHasChanges(true); + applyWorkflowUpdate(workflowData); } catch (error) { console.error( "Failed to parse and apply workflow", diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx index eff44658..6444baca 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx @@ -1,6 +1,7 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Cross2Icon } from "@radix-ui/react-icons"; import { ReactFlowProvider, useNodesState, @@ -22,10 +23,16 @@ type BlockComparison = { identifier: string; }; +type ComparisonMode = "history" | "copilot"; + +export type CopilotReviewStatus = "approve" | "reject" | "close"; + type Props = { version1: WorkflowVersion; version2: WorkflowVersion; onSelectState?: (version: WorkflowVersion) => void; + mode?: ComparisonMode; + onCopilotReviewClose?: (status: CopilotReviewStatus) => void; }; // Mapping from WorkflowBlock.block_type to ReactFlow node.type @@ -255,7 +262,13 @@ function WorkflowComparisonRenderer({ ); } -function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { +function WorkflowComparisonPanel({ + version1, + version2, + onSelectState, + mode = "history", + onCopilotReviewClose, +}: Props) { const comparisons = useMemo(() => { const blocks1 = version1?.workflow_definition?.blocks || []; const blocks2 = version2?.workflow_definition?.blocks || []; @@ -265,6 +278,20 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { version2?.workflow_definition?.blocks, ]); + // ESC key handler for copilot mode - close without rejecting + useEffect(() => { + if (mode !== "copilot" || !onCopilotReviewClose) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCopilotReviewClose("close"); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [mode, onCopilotReviewClose]); + // Statistics const stats = useMemo( () => ({ @@ -321,7 +348,18 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { return (
{/* Header */} -
+
+ {/* Close button for copilot mode */} + {mode === "copilot" && onCopilotReviewClose && ( + + )} {/* 3x3 Grid Layout */}
{/* Row 1: Workflow Names and Title */} @@ -329,17 +367,21 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { {version1.title}

- Version Comparison + {mode === "copilot" ? "Copilot Review" : "Version Comparison"}

{version2.title}

{/* Row 2: Version Details and Statistics */} -
- [Version {version1.version}] •{" "} - {new Date(version1.modified_at).toLocaleDateString()} -
+ {mode === "history" ? ( +
+ [Version {version1.version}] •{" "} + {new Date(version1.modified_at).toLocaleDateString()} +
+ ) : ( +
+ )}
@@ -358,14 +400,18 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) { Removed ({stats.removed})
-
- [Version {version2.version}] •{" "} - {new Date(version2.modified_at).toLocaleDateString()} -
+ {mode === "history" ? ( +
+ [Version {version2.version}] •{" "} + {new Date(version2.modified_at).toLocaleDateString()} +
+ ) : ( +
+ )} - {/* Row 3: Select Buttons */} + {/* Row 3: Action Buttons */}
- {onSelectState && ( + {mode === "history" && onSelectState && ( )}
-
+
+ {mode === "copilot" && ( + <> + + + + )} +
- {onSelectState && ( + {mode === "history" && onSelectState && (