Workflow Copilot: review and approve/reject changes (#4559)

This commit is contained in:
Stanislav Novosad
2026-01-27 13:24:44 -07:00
committed by GitHub
parent cb2a72775d
commit c0f361bb6e
10 changed files with 481 additions and 80 deletions

View File

@@ -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 (
<div className="flex items-start gap-3">
<div
@@ -55,6 +61,7 @@ const MessageItem = memo(({ message }: { message: ChatMessage }) => {
<p className="whitespace-pre-wrap pr-3 text-sm text-slate-200">
{message.content}
</p>
{footer ? <div className="mt-3 flex gap-2">{footer}</div> : null}
{message.timestamp ? (
<span className="pointer-events-none absolute bottom-2 right-2 rounded bg-slate-900/70 px-1.5 py-0.5 text-[10px] text-slate-400">
{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<ChatMessage[]>([]);
const [proposedWorkflow, setProposedWorkflow] =
useState<WorkflowApiResponse | null>(null);
const [autoAccept, setAutoAccept] = useState<boolean>(false);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [processingStatus, setProcessingStatus] = useState<string>("");
@@ -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<WorkflowCopilotClearProposedWorkflowRequest>(
"/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({
</p>
</div>
) : null}
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const showProposedPanel = isLastMessage && proposedWorkflow;
return (
<MessageItem
key={message.id}
message={message}
footer={
showProposedPanel ? (
<>
<button
type="button"
onClick={() => handleReviewWorkflow(proposedWorkflow)}
className="rounded border border-blue-500/60 bg-blue-500/10 px-3 py-1 text-xs text-blue-100 hover:bg-blue-500/20"
>
Review
</button>
<button
type="button"
onClick={() => handleAcceptWorkflow(proposedWorkflow)}
className="rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700"
>
Accept
</button>
<button
type="button"
onClick={() =>
handleAcceptWorkflow(proposedWorkflow, true)
}
className="rounded bg-emerald-600 px-3 py-1 text-xs text-white hover:bg-emerald-700"
>
Always accept
</button>
<button
type="button"
onClick={handleRejectWorkflow}
className="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
>
Reject
</button>
</>
) : null
}
/>
);
})}
{isLoading && (
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">

View File

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

View File

@@ -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({
<div
className="absolute left-6 top-[6rem]"
style={{
width: "calc(100% - 32rem)",
width: workflowPanelState.active
? "calc(100% - 32rem)"
: "calc(100% - 3rem)",
height: "calc(100vh - 11rem)",
}}
>
@@ -1248,6 +1289,10 @@ function Workspace({
version1={workflowPanelState.data.version1}
version2={workflowPanelState.data.version2}
onSelectState={handleSelectState}
mode={workflowPanelState.data.mode}
onCopilotReviewClose={
workflowPanelState.data.onCopilotReviewClose
}
/>
</div>
@@ -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",

View File

@@ -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 (
<div className="flex h-full w-full flex-col rounded-lg bg-slate-elevation2">
{/* Header */}
<div className="flex-shrink-0 p-4 pb-3">
<div className="relative flex-shrink-0 p-4 pb-3">
{/* Close button for copilot mode */}
{mode === "copilot" && onCopilotReviewClose && (
<button
type="button"
onClick={() => onCopilotReviewClose("close")}
className="absolute right-4 top-4 rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
title="Close (Esc)"
>
<Cross2Icon className="h-5 w-5" />
</button>
)}
{/* 3x3 Grid Layout */}
<div className="grid grid-cols-3 gap-4">
{/* Row 1: Workflow Names and Title */}
@@ -329,17 +367,21 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
{version1.title}
</h2>
<h3 className="text-center text-lg font-medium text-muted-foreground">
Version Comparison
{mode === "copilot" ? "Copilot Review" : "Version Comparison"}
</h3>
<h2 className="text-center text-xl font-semibold">
{version2.title}
</h2>
{/* Row 2: Version Details and Statistics */}
<div className="text-center text-sm text-muted-foreground">
[Version {version1.version}] {" "}
{new Date(version1.modified_at).toLocaleDateString()}
</div>
{mode === "history" ? (
<div className="text-center text-sm text-muted-foreground">
[Version {version1.version}] {" "}
{new Date(version1.modified_at).toLocaleDateString()}
</div>
) : (
<div></div>
)}
<div className="flex justify-center gap-3 text-sm">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-green-300"></div>
@@ -358,14 +400,18 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
<span>Removed ({stats.removed})</span>
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
[Version {version2.version}] {" "}
{new Date(version2.modified_at).toLocaleDateString()}
</div>
{mode === "history" ? (
<div className="text-center text-sm text-muted-foreground">
[Version {version2.version}] {" "}
{new Date(version2.modified_at).toLocaleDateString()}
</div>
) : (
<div></div>
)}
{/* Row 3: Select Buttons */}
{/* Row 3: Action Buttons */}
<div className="flex justify-center">
{onSelectState && (
{mode === "history" && onSelectState && (
<Button
size="sm"
onClick={() => onSelectState(version1)}
@@ -375,9 +421,29 @@ function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
</Button>
)}
</div>
<div></div>
<div className="flex justify-center gap-2">
{mode === "copilot" && (
<>
<Button
size="sm"
variant="destructive"
onClick={() => onCopilotReviewClose?.("reject")}
className="text-xs"
>
Reject
</Button>
<Button
size="sm"
onClick={() => onCopilotReviewClose?.("approve")}
className="bg-green-600 text-xs hover:bg-green-700"
>
Accept
</Button>
</>
)}
</div>
<div className="flex justify-center">
{onSelectState && (
{mode === "history" && onSelectState && (
<Button
size="sm"
onClick={() => onSelectState(version2)}

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery";
import { CopilotReviewStatus } from "@/routes/workflows/editor/panels/WorkflowComparisonPanel";
export type BranchContext = {
conditionalNodeId: string;
@@ -27,6 +28,8 @@ type WorkflowPanelState = {
version1?: WorkflowVersion;
version2?: WorkflowVersion;
showComparison?: boolean;
mode?: "history" | "copilot";
onCopilotReviewClose?: (status: CopilotReviewStatus) => void;
};
};