diff --git a/.changeset/cuddly-years-fall.md b/.changeset/cuddly-years-fall.md new file mode 100644 index 000000000..3850f8e53 --- /dev/null +++ b/.changeset/cuddly-years-fall.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +--- + +Add fork workflow feature to create fresh runs from existing durable workflow executions + diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 63d8e2892..0fe1676ee 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -37,6 +37,8 @@ "format": "biome format --write" }, "dependencies": { + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-slot": "1.2.3", "@tailwindcss/postcss": "4", "@workflow/core": "workspace:*", "@workflow/errors": "workspace:*", diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 0c5fb409e..0cce0e3c8 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -7,6 +7,7 @@ import type { WorkflowRun, WorkflowRunStatus, } from '@workflow/world'; +import type { ModelMessage } from 'ai'; import { useCallback, useEffect, useRef, useState } from 'react'; import { getPaginationDisplay } from '../lib/utils'; import type { EnvMap, ServerActionError } from './workflow-server-actions'; @@ -21,6 +22,7 @@ import { fetchStep, fetchSteps, fetchStreams, + forkRunFromConversation, readStreamServerAction, recreateRun as recreateRunServerAction, } from './workflow-server-actions'; @@ -1101,6 +1103,31 @@ export async function recreateRun(env: EnvMap, runId: string): Promise { return resultData; } +/** + * Fork a workflow run from a specific point in a conversation. + * + * Creates a new run with a truncated conversation, allowing the LLM to + * generate a fresh response from that point. + * + * @param env - Environment variables for world configuration + * @param runId - The original run ID to fork from + * @param truncatedMessages - The conversation messages truncated to the fork point + * @returns The new run ID + */ +export async function forkRun( + env: EnvMap, + runId: string, + truncatedMessages: ModelMessage[] +): Promise { + const { error, result: resultData } = await unwrapServerActionResult( + forkRunFromConversation(env, runId, truncatedMessages) + ); + if (error) { + throw error; + } + return resultData; +} + function isServerActionError(value: unknown): value is ServerActionError { return ( typeof value === 'object' && diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 8f1800f48..5efd637b5 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -17,6 +17,7 @@ import type { WorkflowRunStatus, World, } from '@workflow/world'; +import type { ModelMessage } from 'ai'; export type EnvMap = Record; @@ -507,6 +508,62 @@ export async function recreateRun( } } +/** + * Fork a workflow run from a specific point in a conversation. + * + * This creates a new run with a truncated conversation - all messages up to and + * including the specified message index. The workflow will re-execute with this + * modified input, allowing the LLM to generate a fresh response. + * + * System messages are filtered out from the truncated conversation since the + * workflow's DurableAgent will add its own system message, preventing duplicates. + * + * @param worldEnv - Environment variables for world configuration + * @param runId - The original run ID to fork from + * @param truncatedMessages - The conversation messages truncated to the fork point + */ +export async function forkRunFromConversation( + worldEnv: EnvMap, + runId: string, + truncatedMessages: ModelMessage[] +): Promise> { + try { + const world = getWorldFromEnv({ ...worldEnv }); + const run = await world.runs.get(runId); + const hydratedRun = hydrate(run as WorkflowRun); + const deploymentId = run.deploymentId; + + // Filter out system messages from the truncated conversation. + // The workflow's DurableAgent will add its own system message, so including + // one from the original conversation would cause duplication. + const messagesWithoutSystem = truncatedMessages.filter((msg) => { + if (msg && typeof msg === 'object' && 'role' in msg) { + return (msg as { role: string }).role !== 'system'; + } + return true; + }); + + // The input for doStreamStep is [conversationPrompt, model, writable, tools, options] + // We need to replace the first argument (conversation) with the truncated version + const modifiedInput = [...hydratedRun.input]; + modifiedInput[0] = messagesWithoutSystem; + + const newRun = await start( + { workflowId: run.workflowName }, + modifiedInput, + { + deploymentId, + } + ); + return createResponse(newRun.runId); + } catch (error) { + return createServerActionError(error, 'forkRunFromConversation', { + runId, + messageCount: truncatedMessages.length, + }); + } +} + export async function readStreamServerAction( env: EnvMap, streamId: string, diff --git a/packages/web-shared/src/components/ui/button.tsx b/packages/web-shared/src/components/ui/button.tsx new file mode 100644 index 000000000..d3989b9fe --- /dev/null +++ b/packages/web-shared/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/packages/web-shared/src/components/ui/dialog.tsx b/packages/web-shared/src/components/ui/dialog.tsx new file mode 100644 index 000000000..341e165f7 --- /dev/null +++ b/packages/web-shared/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index f78effcd3..0933e6235 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -7,24 +7,136 @@ import { AlertCircle } from 'lucide-react'; import { createContext, type ReactNode, + useCallback, useContext, useMemo, useState, } from 'react'; +import { forkRun } from '../api/workflow-api-client'; +import type { EnvMap } from '../api/workflow-server-actions'; import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../components/ui/dialog'; import { extractConversation, isDoStreamStep } from '../lib/utils'; import { ConversationView } from './conversation-view'; import { DetailCard } from './detail-card'; +/** + * Confirmation dialog for forking a workflow run + */ +function ForkConfirmationDialog({ + open, + previewMessages, + isForking, + onConfirm, + onCancel, +}: { + open: boolean; + previewMessages: ModelMessage[]; + isForking: boolean; + onConfirm: (editedMessages: ModelMessage[]) => void; + onCancel: () => void; +}) { + // Filter out system messages for the preview (they won't be sent) + const messagesToShow = previewMessages.filter((m) => m.role !== 'system'); + + // Track edited messages - initialize when dialog opens + const [editedMessages, setEditedMessages] = + useState(messagesToShow); + + // Track if user is currently editing a message + const [isEditingMessage, setIsEditingMessage] = useState(false); + + // Reset edited messages when previewMessages change + useMemo(() => { + setEditedMessages(messagesToShow); + }, [previewMessages]); + + // Handle user message edits (only user messages can be edited) + const handleMessageChange = useCallback( + (messageIndex: number, newContent: string) => { + setEditedMessages((prev) => + prev.map((msg, idx) => { + if (idx !== messageIndex || msg.role !== 'user') return msg; + // For user messages, content can be a string or array of content parts + // We replace with a simple string content + return { + role: 'user' as const, + content: newContent, + providerOptions: msg.providerOptions, + }; + }) + ); + }, + [] + ); + + return ( + !isOpen && onCancel()}> + + + Fork Workflow + + A new workflow run will be created with the following{' '} + {editedMessages.length} message + {editedMessages.length !== 1 ? 's' : ''} as input. You can edit the + last user message before forking. + + + + {/* Preview content */} +
+
+ +
+
+ + + + + +
+
+ ); +} + /** * Tabbed view for conversation and raw JSON */ function ConversationWithTabs({ conversation, args, + onFork, }: { conversation: ModelMessage[]; args: unknown[]; + onFork?: (messageIndex: number) => void; }) { const [activeTab, setActiveTab] = useState<'conversation' | 'json'>( 'conversation' @@ -81,7 +193,7 @@ function ConversationWithTabs({ {/* Tab content */} {activeTab === 'conversation' ? ( - + ) : (
{Array.isArray(args) @@ -324,6 +436,7 @@ const sortByAttributeOrder = (a: string, b: string): number => { interface DisplayContext { stepName?: string; + onFork?: (messageIndex: number) => void; } const attributeToDisplayFn: Record< @@ -381,7 +494,11 @@ const attributeToDisplayFn: Record< if (conversation && conversation.length > 0) { return ( <> - + {hasClosureVars && ( {JsonBlock(closureVars)} @@ -588,6 +705,9 @@ export const AttributePanel = ({ error, expiredAt, onStreamClick, + env, + runId, + onForkComplete, }: { data: Record; isLoading?: boolean; @@ -595,6 +715,12 @@ export const AttributePanel = ({ expiredAt?: string | Date; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; + /** Environment variables for API calls (required for fork functionality) */ + env?: EnvMap; + /** Run ID for forking (required for fork functionality) */ + runId?: string; + /** Callback when fork is complete, receives the new run ID */ + onForkComplete?: (newRunId: string) => void; }) => { const displayData = data; const hasExpired = expiredAt != null && new Date(expiredAt) < new Date(); @@ -616,12 +742,62 @@ export const AttributePanel = ({ return displayValue !== null; }); + // Extract conversation for fork functionality + const inputData = displayData.input as + | { args?: unknown[]; closureVars?: Record } + | undefined; + const conversation = inputData?.args + ? extractConversation(inputData.args) + : null; + + // Fork dialog state + const [forkPreview, setForkPreview] = useState(null); + const [isForking, setIsForking] = useState(false); + + // Opens the fork confirmation dialog with preview + const handleForkClick = useCallback( + (messageIndex: number) => { + if (!conversation) return; + // Truncate conversation up to and including the selected message + const truncatedMessages = conversation.slice(0, messageIndex + 1); + setForkPreview(truncatedMessages); + }, + [conversation] + ); + + // Confirms and executes the fork with the (potentially edited) messages + const handleForkConfirm = useCallback( + async (editedMessages: ModelMessage[]) => { + if (!env || !runId) return; + + setIsForking(true); + try { + const newRunId = await forkRun(env, runId, editedMessages); + setForkPreview(null); + onForkComplete?.(newRunId); + } catch (err) { + console.error('Failed to fork workflow:', err); + } finally { + setIsForking(false); + } + }, + [env, runId, onForkComplete] + ); + + // Cancels the fork dialog + const handleForkCancel = useCallback(() => { + if (!isForking) { + setForkPreview(null); + } + }, [isForking]); + // Memoize context object to avoid object reconstruction on render const displayContext = useMemo( () => ({ stepName: displayData.stepName as string | undefined, + onFork: env && runId && conversation ? handleForkClick : undefined, }), - [displayData.stepName] + [displayData.stepName, env, runId, conversation, handleForkClick] ); return ( @@ -683,6 +859,15 @@ export const AttributePanel = ({ /> )) )} + + {/* Fork confirmation dialog */} +
); diff --git a/packages/web-shared/src/sidebar/conversation-view.tsx b/packages/web-shared/src/sidebar/conversation-view.tsx index 3011f67bf..e6e3fc1b3 100644 --- a/packages/web-shared/src/sidebar/conversation-view.tsx +++ b/packages/web-shared/src/sidebar/conversation-view.tsx @@ -1,11 +1,27 @@ import type { ModelMessage } from 'ai'; +import { Check, GitBranch, Pencil } from 'lucide-react'; +import { useState } from 'react'; import { Streamdown } from 'streamdown'; interface ConversationViewProps { messages: ModelMessage[]; + /** Callback when user clicks fork button on a user message. Receives the message index. */ + onFork?: (messageIndex: number) => void; + /** Enable editing of user messages */ + editable?: boolean; + /** Callback when a user message is edited. Receives the message index and new content. */ + onMessageChange?: (messageIndex: number, newContent: string) => void; + /** Callback when editing state changes */ + onEditingChange?: (isEditing: boolean) => void; } -export function ConversationView({ messages }: ConversationViewProps) { +export function ConversationView({ + messages, + onFork, + editable, + onMessageChange, + onEditingChange, +}: ConversationViewProps) { if (messages.length === 0) { return (
(msg.role === 'user' ? idx : lastIdx), + -1 + ); + return (
{messages.map((message, index) => ( - + ))}
); } -function MessageBubble({ message }: { message: ModelMessage }) { +function MessageBubble({ + message, + index, + onFork, + editable, + onMessageChange, + onEditingChange, +}: { + message: ModelMessage; + index: number; + onFork?: (messageIndex: number) => void; + editable?: boolean; + onMessageChange?: (messageIndex: number, newContent: string) => void; + onEditingChange?: (isEditing: boolean) => void; +}) { const role = message.role; - const parts = parseContent(message.content); const style = getRoleStyle(role); + const showForkButton = role === 'user' && onFork; + const canEdit = editable && role === 'user' && onMessageChange; + + // Local editing state + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(''); + + // Extract text content for editing + const textContent = getTextContent(message.content); + + const handleStartEdit = () => { + setEditValue(textContent); + setIsEditing(true); + onEditingChange?.(true); + }; + + const handleSaveEdit = () => { + if (onMessageChange) { + onMessageChange(index, editValue); + } + setIsEditing(false); + onEditingChange?.(false); + }; return (
{/* Role header */}
- {role} + {role} +
+ {canEdit && !isEditing && ( + + )} + {canEdit && isEditing && ( + + )} + {showForkButton && ( + + )} +
{/* Content */}
- {parts.map((part, i) => ( - - ))} + {isEditing ? ( +