diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a30..ec5e9dca1 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -161,6 +161,33 @@ export function useAppNavigation() { [commitNavigation], ); + const goNewWorkflow = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows", + search: { view: "create" }, + }, + behavior, + ), + [commitNavigation], + ); + + const goDuplicateWorkflow = React.useCallback( + (workflowId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workflows/$workflowId", + params: { + workflowId, + }, + search: { view: "duplicate" }, + }, + behavior, + ), + [commitNavigation], + ); + const goChannel = React.useCallback( ( channelId: string, @@ -266,7 +293,7 @@ export function useAppNavigation() { void goHome({ replace: true }); }, [canGoBack, goHome, router.history]); - const closeWorkflowDetail = React.useCallback(() => { + const closeWorkflowEditor = React.useCallback(() => { if (canGoBack) { router.history.back(); return; @@ -313,11 +340,13 @@ export function useAppNavigation() { return { closeForumPost, closeSettings, - closeWorkflowDetail, + closeWorkflowEditor, goAgents, goChannel, goForumPost, goHome, + goDuplicateWorkflow, + goNewWorkflow, goNewMessage, goProject, goProjects, diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx index ce72e5080..9c984f291 100644 --- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx +++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx @@ -1,15 +1,23 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; -import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen"; +import { + type WorkflowEditorRoute, + WorkflowsScreen, +} from "@/features/workflows/ui/WorkflowsScreen"; type WorkflowsRouteScreenProps = { - selectedWorkflowId: string | null; + editor?: WorkflowEditorRoute | null; }; export function WorkflowsRouteScreen({ - selectedWorkflowId, + editor = null, }: WorkflowsRouteScreenProps) { - const { closeWorkflowDetail } = useAppNavigation(); + const { + closeWorkflowEditor, + goDuplicateWorkflow, + goNewWorkflow, + goWorkflow, + } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; const memberChannels = channels.filter((channel) => channel.isMember); @@ -17,8 +25,17 @@ export function WorkflowsRouteScreen({ return ( { + void goNewWorkflow(); + }} + onDuplicateWorkflow={(workflowId) => { + void goDuplicateWorkflow(workflowId); + }} + onEditWorkflow={(workflowId) => { + void goWorkflow(workflowId); + }} /> ); } diff --git a/desktop/src/app/routes/workflows.$workflowId.tsx b/desktop/src/app/routes/workflows.$workflowId.tsx index f6a74aa15..d773e8959 100644 --- a/desktop/src/app/routes/workflows.$workflowId.tsx +++ b/desktop/src/app/routes/workflows.$workflowId.tsx @@ -5,7 +5,10 @@ import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; export const Route = createFileRoute("/workflows/$workflowId")({ - component: WorkflowDetailRouteComponent, + component: WorkflowEditorRouteComponent, + validateSearch: (search: Record) => ({ + view: search.view === "duplicate" ? search.view : undefined, + }), }); const WorkflowsRouteScreen = React.lazy(async () => { @@ -13,13 +16,19 @@ const WorkflowsRouteScreen = React.lazy(async () => { return { default: module.WorkflowsRouteScreen }; }); -function WorkflowDetailRouteComponent() { +function WorkflowEditorRouteComponent() { usePreviewFeatureWarning("workflows"); const { workflowId } = Route.useParams(); + const { view } = Route.useSearch(); return ( }> - + ); } diff --git a/desktop/src/app/routes/workflows.tsx b/desktop/src/app/routes/workflows.tsx index 7ab6461fd..7358f4e29 100644 --- a/desktop/src/app/routes/workflows.tsx +++ b/desktop/src/app/routes/workflows.tsx @@ -6,6 +6,9 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; export const Route = createFileRoute("/workflows")({ component: WorkflowsRouteComponent, + validateSearch: (search: Record) => ({ + view: search.view === "create" ? search.view : undefined, + }), }); const WorkflowsRouteScreen = React.lazy(async () => { @@ -15,9 +18,13 @@ const WorkflowsRouteScreen = React.lazy(async () => { function WorkflowsRouteComponent() { usePreviewFeatureWarning("workflows"); + const { view } = Route.useSearch(); + return ( }> - + ); } diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 3b9f26f0b..fccc10355 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -52,7 +52,6 @@ import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation type WorkflowCardProps = { workflow: Workflow; channelName?: string; - isActive?: boolean; isTogglingEnabled?: boolean; onTrigger: (workflowId: string) => void; onToggleEnabled: (workflow: Workflow) => void; @@ -208,7 +207,6 @@ function TriggerCardText({ export function WorkflowCard({ workflow, channelName, - isActive = false, isTogglingEnabled = false, onTrigger, onToggleEnabled, @@ -266,8 +264,6 @@ export function WorkflowCard({ className={cn( "group relative min-h-60 w-full overflow-hidden rounded-2xl border p-5 text-left shadow-sm transition-shadow duration-200 hover:shadow-lg", theme ?? "border-slate-500/30 bg-slate-700 text-white", - isActive && - "ring-2 ring-primary ring-offset-2 ring-offset-background shadow-lg", )} data-testid={`workflow-card-${workflow.id}`} > diff --git a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx deleted file mode 100644 index 3bc94a898..000000000 --- a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { ChevronDown, ChevronRight, Pencil, Play, X } from "lucide-react"; -import * as React from "react"; - -import { - useRunApprovalsQuery, - useTriggerWorkflowMutation, - useWorkflowQuery, - useWorkflowRunsQuery, -} from "@/features/workflows/hooks"; -import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace"; -import type { Workflow } from "@/shared/api/types"; -import { Badge, type BadgeProps } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { - getWorkflowDescription, - getWorkflowDisplayStatus, - getWorkflowTriggerSummary, -} from "./workflowDefinition"; - -type WorkflowDetailPanelProps = { - workflowId: string; - onClose: () => void; - onEdit: (workflow: Workflow) => void; -}; - -export function WorkflowDetailPanel({ - workflowId, - onClose, - onEdit, -}: WorkflowDetailPanelProps) { - const workflowQuery = useWorkflowQuery(workflowId); - const runsQuery = useWorkflowRunsQuery(workflowId); - const triggerMutation = useTriggerWorkflowMutation(workflowId); - const [selectedRunId, setSelectedRunId] = React.useState(null); - - const workflow = workflowQuery.data; - const runs = runsQuery.data ?? []; - const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId); - const workflowDescription = workflow - ? getWorkflowDescription(workflow.definition) - : null; - const triggerSummary = workflow - ? getWorkflowTriggerSummary(workflow.definition) - : null; - const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null; - const triggerError = errorMessage( - triggerMutation.error, - "The relay did not create a workflow run.", - ); - const runsError = errorMessage( - runsQuery.error, - "Run history could not be loaded.", - ); - const selectedRunIsPendingHistory = - selectedRunId !== null && !runs.some((run) => run.id === selectedRunId); - - async function handleTrigger() { - try { - const response = await triggerMutation.mutateAsync(); - setSelectedRunId(response.runId); - } catch { - // React Query stores the error; keep the current selection unchanged. - } - } - - return ( -
-
-
-
- {workflow ? ( -

- {workflow.name} -

- ) : ( - - )} - {workflowStatus ? : null} -
- {workflowDescription ? ( -

- {workflowDescription} -

- ) : workflowQuery.isLoading ? ( - - ) : null} - {triggerSummary ? ( -

- {triggerSummary} -

- ) : workflowQuery.isLoading ? ( - - ) : null} -
-
- {workflow ? ( - - ) : null} - - -
-
- - {triggerMutation.isError ? ( -
-

Failed to trigger workflow

-

- {triggerError} -

-
- ) : null} - -
- {workflow ? ( -
-
-

- Definition -

-
-                {JSON.stringify(workflow.definition, null, 2)}
-              
-
- -
-

- Run History -

- {runsQuery.isError ? ( -
-

Failed to load run history

-

{runsError}

-
- ) : runsQuery.isLoading ? ( -
- -
- ) : selectedRunIsPendingHistory ? ( -
-

Run created

-

- {selectedRunId} -

-

- Waiting for its persisted trace… -

-
- ) : runs.length === 0 ? ( -

No runs yet.

- ) : ( -
- {runs.map((run) => { - const isSelected = selectedRunId === run.id; - const duration = formatRunDuration( - run.startedAt, - run.completedAt, - ); - const failureReason = workflowRunFailureReason( - run.errorCode, - run.errorMessage, - ); - - return ( -
- - - {isSelected ? ( -
-
- Execution Trace - {approvalsQuery.isFetching ? ( - - Refreshing approvals... - - ) : null} -
- {approvalsQuery.error instanceof Error ? ( -

- {approvalsQuery.error.message} -

- ) : null} - -
- ) : null} -
- ); - })} -
- )} -
-
- ) : workflowQuery.isError ? ( -
-

Failed to load workflow

-
- ) : ( -
-
- - -
-
- -
- - -
-
-
- )} -
-
- ); -} - -function workflowRunFailureReason( - errorCode: string | null, - diagnostic: string | null, -) { - if (diagnostic?.trim()) return diagnostic; - if (!errorCode) return null; - const knownReasons: Record = { - approval_denied: "Approval was denied.", - approval_expired: "Approval expired before the workflow could continue.", - external_outcome_unknown: - "The external action may have completed, but its outcome could not be confirmed.", - run_interrupted: "The run was interrupted before it could finish.", - }; - return ( - knownReasons[errorCode] ?? `Run failed (${errorCode.replace(/_/g, " ")}).` - ); -} - -function errorMessage(error: unknown, fallback: string) { - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : fallback; -} - -function formatRunDuration( - startedAt: number | null, - completedAt: number | null, -) { - if (startedAt === null || completedAt === null) return null; - const seconds = completedAt - startedAt; - if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; - return `${seconds.toFixed(1)}s`; -} - -function formatStatusLabel(status: string) { - return status.replace(/_/g, " "); -} - -function RunStatusBadge({ status }: { status: string }) { - const variants: Record = { - active: "success", - disabled: "secondary", - archived: "warning", - completed: "success", - failed: "destructive", - running: "info", - pending: "secondary", - cancelled: "secondary", - waiting_approval: "warning", - }; - - return ( - - {formatStatusLabel(status)} - - ); -} diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 5c9a76047..b599a909a 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { Code } from "lucide-react"; +import { useBlocker } from "@tanstack/react-router"; import { stringify as yamlStringify } from "yaml"; import { @@ -113,6 +114,8 @@ export function WorkflowDialog({ channelId, yaml: getInitialYaml(mode, workflow), }); + const allowNavigationRef = React.useRef(false); + const proceedingNavigationRef = React.useRef(false); const createMutation = useCreateWorkflowMutation(selectedChannelId); const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? ""); @@ -156,6 +159,17 @@ export function WorkflowDialog({ const isDirty = yamlDefinition !== initialValuesRef.current.yaml || selectedChannelId !== initialValuesRef.current.channelId; + const navigationBlocker = useBlocker({ + enableBeforeUnload: isDirty, + shouldBlockFn: () => isDirty && !allowNavigationRef.current, + withResolver: true, + }); + + React.useEffect(() => { + if (navigationBlocker.status === "blocked") { + setDiscardConfirmationOpen(true); + } + }, [navigationBlocker.status]); const handleOpenChange = React.useCallback( (nextOpen: boolean) => { @@ -175,7 +189,6 @@ export function WorkflowDialog({ try { const saved = await mutation.mutateAsync(yamlDefinition); - closeDialog(); if (saved.webhookSecret) { const relayHttpUrl = await getRelayHttpUrl(); setSavedWebhookInfo({ @@ -183,6 +196,9 @@ export function WorkflowDialog({ webhookSecret: saved.webhookSecret, workflowId: saved.workflow.id, }); + } else { + allowNavigationRef.current = true; + closeDialog(); } } catch { // React Query stores the error; keep the dialog open. @@ -220,7 +236,10 @@ export function WorkflowDialog({ return ( <> - +
@@ -333,7 +352,16 @@ export function WorkflowDialog({
{ + setDiscardConfirmationOpen(nextOpen); + if ( + !nextOpen && + navigationBlocker.status === "blocked" && + !proceedingNavigationRef.current + ) { + navigationBlocker.reset(); + } + }} open={discardConfirmationOpen} > @@ -350,7 +378,20 @@ export function WorkflowDialog({ - @@ -362,7 +403,8 @@ export function WorkflowDialog({ { if (!nextOpen) { - setSavedWebhookInfo(null); + allowNavigationRef.current = true; + closeDialog(); } }} open diff --git a/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx b/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx deleted file mode 100644 index 76fc9ecf8..000000000 --- a/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Check, Clock, SkipForward, X } from "lucide-react"; - -import type { WorkflowApproval, WorkflowRun } from "@/shared/api/types"; -import { Badge, type BadgeProps } from "@/shared/ui/badge"; -import { WorkflowApprovalCard } from "@/features/workflows/ui/WorkflowApprovalCard"; - -type WorkflowRunTraceProps = { - run: WorkflowRun; - approvals?: WorkflowApproval[]; -}; - -function formatStatusLabel(status: string) { - return status.replace(/_/g, " "); -} - -function StepStatusBadge({ status }: { status: string }) { - const variants: Record = { - completed: "success", - failed: "destructive", - error: "destructive", - running: "info", - pending: "secondary", - cancelled: "secondary", - skipped: "secondary", - waiting_approval: "warning", - }; - - return ( - - {formatStatusLabel(status)} - - ); -} - -function StepStatusIcon({ status }: { status: string }) { - switch (status) { - case "completed": - return ; - case "failed": - case "error": - return ; - case "skipped": - return ; - case "waiting_approval": - return ; - default: - return ; - } -} - -function formatDuration(startedAt: number | null, completedAt: number | null) { - if (startedAt === null || completedAt === null) return null; - const seconds = completedAt - startedAt; - if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; - return `${seconds.toFixed(1)}s`; -} - -export function WorkflowRunTrace({ - run, - approvals = [], -}: WorkflowRunTraceProps) { - if (run.executionTrace.length === 0) { - return ( -

- No steps recorded yet. -

- ); - } - - return ( -
- {run.executionTrace.map((step) => { - const duration = formatDuration(step.startedAt, step.completedAt); - const pendingApproval = approvals.find( - (a) => a.stepId === step.stepId && a.status === "pending", - ); - - return ( -
-
- - - {step.stepId} - - - {duration ? ( - - {duration} - - ) : null} -
- {Object.keys(step.output).length > 0 ? ( -
-

- Output -

-
-                  {JSON.stringify(step.output, null, 2)}
-                
-
- ) : null} - {step.error ? ( -
-

- Error -

-
-                  {step.error}
-                
-
- ) : null} - {pendingApproval ? ( -
-

- Pending approval -

- -
- ) : null} -
- ); - })} -
- ); -} diff --git a/desktop/src/features/workflows/ui/WorkflowsScreen.tsx b/desktop/src/features/workflows/ui/WorkflowsScreen.tsx index f997d2b1b..fe578984c 100644 --- a/desktop/src/features/workflows/ui/WorkflowsScreen.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsScreen.tsx @@ -8,24 +8,37 @@ const WorkflowsView = React.lazy(async () => { return { default: module.WorkflowsView }; }); +export type WorkflowEditorRoute = + | { mode: "create" } + | { mode: "duplicate" | "edit"; workflowId: string }; + type WorkflowsScreenProps = { channels: Channel[]; - onCloseWorkflow: () => void; - selectedWorkflowId: string | null; + editor: WorkflowEditorRoute | null; + onCloseEditor: () => void; + onCreateWorkflow: () => void; + onDuplicateWorkflow: (workflowId: string) => void; + onEditWorkflow: (workflowId: string) => void; }; export function WorkflowsScreen({ channels, - onCloseWorkflow, - selectedWorkflowId, + editor, + onCloseEditor, + onCreateWorkflow, + onDuplicateWorkflow, + onEditWorkflow, }: WorkflowsScreenProps) { return (
}>
diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index 0879fe9e0..8a0d43124 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -5,12 +5,13 @@ import { stringify as yamlStringify } from "yaml"; import { allWorkflowsQueryKey, + useWorkflowQuery, workflowListFocusRefetchPolicy, } from "@/features/workflows/hooks"; import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard"; import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog"; -import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel"; import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog"; +import type { WorkflowEditorRoute } from "@/features/workflows/ui/WorkflowsScreen"; import { getWorkflowEnabled, withWorkflowEnabled, @@ -28,8 +29,11 @@ import { Skeleton } from "@/shared/ui/skeleton"; type WorkflowsViewProps = { channels: Channel[]; - onCloseWorkflow: () => void; - selectedWorkflowId: string | null; + editor: WorkflowEditorRoute | null; + onCloseEditor: () => void; + onCreateWorkflow: () => void; + onDuplicateWorkflow: (workflowId: string) => void; + onEditWorkflow: (workflowId: string) => void; }; type WorkflowWithChannel = { @@ -37,12 +41,6 @@ type WorkflowWithChannel = { channelName: string; }; -type DialogState = - | { mode: "closed" } - | { mode: "create" } - | { mode: "edit"; workflow: Workflow } - | { mode: "duplicate"; workflow: Workflow }; - function WorkflowsListSkeleton() { return (
@@ -85,15 +83,19 @@ function CreateWorkflowCard({ onClick }: { onClick: () => void }) { export function WorkflowsView({ channels, - onCloseWorkflow, - selectedWorkflowId, + editor, + onCloseEditor, + onCreateWorkflow, + onDuplicateWorkflow, + onEditWorkflow, }: WorkflowsViewProps) { - const [dialogState, setDialogState] = React.useState({ - mode: "closed", - }); const [deleteTarget, setDeleteTarget] = React.useState(null); const queryClient = useQueryClient(); + const editorWorkflowId = + editor && editor.mode !== "create" ? editor.workflowId : null; + const editorWorkflowQuery = useWorkflowQuery(editorWorkflowId); + const memberChannels = channels.filter((c) => c.isMember); const channelIds = memberChannels.map((c) => c.id).sort(); const channelIdKey = channelIds.join(","); @@ -135,10 +137,7 @@ export function WorkflowsView({ const deleteMutation = useMutation({ mutationFn: (workflowId: string) => deleteWorkflow(workflowId), - onSuccess: (_data, workflowId) => { - if (selectedWorkflowId === workflowId) { - onCloseWorkflow(); - } + onSuccess: () => { void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "workflows" || @@ -188,13 +187,13 @@ export function WorkflowsView({ ); const handleEdit = React.useCallback( - (workflow: Workflow) => setDialogState({ mode: "edit", workflow }), - [], + (workflow: Workflow) => onEditWorkflow(workflow.id), + [onEditWorkflow], ); const handleDuplicate = React.useCallback( - (workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }), - [], + (workflow: Workflow) => onDuplicateWorkflow(workflow.id), + [onDuplicateWorkflow], ); const toggleEnabled = toggleEnabledMutation.mutate; @@ -203,11 +202,11 @@ export function WorkflowsView({ [toggleEnabled], ); - const handleDialogOpenChange = React.useCallback((open: boolean) => { - if (!open) { - setDialogState({ mode: "closed" }); - } - }, []); + const editorWorkflow = + allWorkflows.find(({ workflow }) => workflow.id === editorWorkflowId) + ?.workflow ?? editorWorkflowQuery.data; + const canOpenEditor = + editor?.mode === "create" || editorWorkflow !== undefined; return (
) : ( -
- setDialogState({ mode: "create" })} - /> +
+ {allWorkflows.map(({ workflow, channelName }) => (
- {selectedWorkflowId ? ( -
- -
+ {editor && canOpenEditor ? ( + { + if (!open) onCloseEditor(); + }} + open + workflow={editorWorkflow} + /> ) : null} - - {