mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Route workflow editor modals
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<WorkflowsScreen
|
||||
channels={memberChannels}
|
||||
onCloseWorkflow={closeWorkflowDetail}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
editor={editor}
|
||||
onCloseEditor={closeWorkflowEditor}
|
||||
onCreateWorkflow={() => {
|
||||
void goNewWorkflow();
|
||||
}}
|
||||
onDuplicateWorkflow={(workflowId) => {
|
||||
void goDuplicateWorkflow(workflowId);
|
||||
}}
|
||||
onEditWorkflow={(workflowId) => {
|
||||
void goWorkflow(workflowId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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 (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
|
||||
<WorkflowsRouteScreen
|
||||
editor={{
|
||||
mode: view === "duplicate" ? "duplicate" : "edit",
|
||||
workflowId,
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
export const Route = createFileRoute("/workflows")({
|
||||
component: WorkflowsRouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
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 (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={null} />
|
||||
<WorkflowsRouteScreen
|
||||
editor={view === "create" ? { mode: "create" } : null}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
className="flex h-full flex-col border-l bg-background pt-4"
|
||||
data-testid="workflow-detail-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{workflow ? (
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-36" />
|
||||
)}
|
||||
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
|
||||
</div>
|
||||
{workflowDescription ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{workflowDescription}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-full max-w-64" />
|
||||
) : null}
|
||||
{triggerSummary ? (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{triggerSummary}
|
||||
</p>
|
||||
) : workflowQuery.isLoading ? (
|
||||
<Skeleton className="mt-1 h-3 w-40" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{workflow ? (
|
||||
<Button
|
||||
onClick={() => onEdit(workflow)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={triggerMutation.isPending || workflowQuery.isLoading}
|
||||
onClick={() => void handleTrigger()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="mr-1 h-4 w-4" />
|
||||
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Close detail panel"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggerMutation.isError ? (
|
||||
<div
|
||||
className="border-b px-4 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-medium">Failed to trigger workflow</p>
|
||||
<p className="mt-1 break-words text-muted-foreground">
|
||||
{triggerError}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
data-scroll-restoration-id={`workflow-detail:${workflowId}`}
|
||||
>
|
||||
{workflow ? (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Definition
|
||||
</h4>
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(workflow.definition, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run History
|
||||
</h4>
|
||||
{runsQuery.isError ? (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-medium">Failed to load run history</p>
|
||||
<p className="mt-1 break-words">{runsError}</p>
|
||||
</div>
|
||||
) : runsQuery.isLoading ? (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label="Loading run history"
|
||||
role="status"
|
||||
>
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
) : selectedRunIsPendingHistory ? (
|
||||
<div
|
||||
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-xs"
|
||||
data-testid="workflow-run-created"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-medium">Run created</p>
|
||||
<p className="mt-1 break-all font-mono text-muted-foreground">
|
||||
{selectedRunId}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Waiting for its persisted trace…
|
||||
</p>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
const duration = formatRunDuration(
|
||||
run.startedAt,
|
||||
run.completedAt,
|
||||
);
|
||||
const failureReason = workflowRunFailureReason(
|
||||
run.errorCode,
|
||||
run.errorMessage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border bg-card/70 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-xs"
|
||||
: "border-border/70 hover:bg-muted/20"
|
||||
}`}
|
||||
key={run.id}
|
||||
>
|
||||
<button
|
||||
aria-expanded={isSelected}
|
||||
className="w-full px-4 py-3 text-left"
|
||||
data-testid={
|
||||
isSelected ? "workflow-selected-run" : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedRunId(isSelected ? null : run.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs font-medium">
|
||||
{run.id.slice(0, 8)}
|
||||
</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-2xs text-muted-foreground">
|
||||
<span>
|
||||
{new Date(
|
||||
run.createdAt * 1000,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{run.executionTrace.length}{" "}
|
||||
{run.executionTrace.length === 1
|
||||
? "step"
|
||||
: "steps"}
|
||||
</span>
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{run.currentStep !== null ? (
|
||||
<span>
|
||||
Current step {run.currentStep + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{failureReason ? (
|
||||
<p className="mt-2 break-words pl-6 text-xs text-destructive">
|
||||
{failureReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSelected ? (
|
||||
<div className="border-t border-border/60 bg-background/60 px-4 py-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-2xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span>Execution Trace</span>
|
||||
{approvalsQuery.isFetching ? (
|
||||
<span className="text-2xs tracking-[0.12em] text-muted-foreground/80">
|
||||
Refreshing approvals...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{approvalsQuery.error instanceof Error ? (
|
||||
<p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{approvalsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<WorkflowRunTrace
|
||||
approvals={approvalsQuery.data}
|
||||
run={run}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : workflowQuery.isError ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">Failed to load workflow</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<Skeleton className="mb-2 h-4 w-28" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="mb-2 h-4 w-24" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function workflowRunFailureReason(
|
||||
errorCode: string | null,
|
||||
diagnostic: string | null,
|
||||
) {
|
||||
if (diagnostic?.trim()) return diagnostic;
|
||||
if (!errorCode) return null;
|
||||
const knownReasons: Record<string, string> = {
|
||||
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<string, BadgeProps["variant"]> = {
|
||||
active: "success",
|
||||
disabled: "secondary",
|
||||
archived: "warning",
|
||||
completed: "success",
|
||||
failed: "destructive",
|
||||
running: "info",
|
||||
pending: "secondary",
|
||||
cancelled: "secondary",
|
||||
waiting_approval: "warning",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] ?? "secondary"}>
|
||||
{formatStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<Dialog
|
||||
onOpenChange={handleOpenChange}
|
||||
open={open && savedWebhookInfo === null}
|
||||
>
|
||||
<DialogContent className="flex h-[88vh] max-h-[88vh] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="flex flex-shrink-0 flex-row items-center justify-between gap-6 border-b border-border px-6 py-5 pr-14 text-left">
|
||||
<div className="space-y-1.5">
|
||||
@@ -333,7 +352,16 @@ export function WorkflowDialog({
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setDiscardConfirmationOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setDiscardConfirmationOpen(nextOpen);
|
||||
if (
|
||||
!nextOpen &&
|
||||
navigationBlocker.status === "blocked" &&
|
||||
!proceedingNavigationRef.current
|
||||
) {
|
||||
navigationBlocker.reset();
|
||||
}
|
||||
}}
|
||||
open={discardConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
@@ -350,7 +378,20 @@ export function WorkflowDialog({
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={closeDialog} type="button" variant="destructive">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDiscardConfirmationOpen(false);
|
||||
if (navigationBlocker.status === "blocked") {
|
||||
proceedingNavigationRef.current = true;
|
||||
navigationBlocker.proceed();
|
||||
return;
|
||||
}
|
||||
allowNavigationRef.current = true;
|
||||
closeDialog();
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
@@ -362,7 +403,8 @@ export function WorkflowDialog({
|
||||
<WorkflowWebhookSecretDialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setSavedWebhookInfo(null);
|
||||
allowNavigationRef.current = true;
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
open
|
||||
|
||||
@@ -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<string, BadgeProps["variant"]> = {
|
||||
completed: "success",
|
||||
failed: "destructive",
|
||||
error: "destructive",
|
||||
running: "info",
|
||||
pending: "secondary",
|
||||
cancelled: "secondary",
|
||||
skipped: "secondary",
|
||||
waiting_approval: "warning",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] ?? "secondary"}>
|
||||
{formatStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function StepStatusIcon({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
case "error":
|
||||
return <X className="h-4 w-4 text-red-500" />;
|
||||
case "skipped":
|
||||
return <SkipForward className="h-4 w-4 text-muted-foreground" />;
|
||||
case "waiting_approval":
|
||||
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<p className="rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No steps recorded yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="workflow-run-trace">
|
||||
{run.executionTrace.map((step) => {
|
||||
const duration = formatDuration(step.startedAt, step.completedAt);
|
||||
const pendingApproval = approvals.find(
|
||||
(a) => a.stepId === step.stepId && a.status === "pending",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 p-3 shadow-xs"
|
||||
key={step.stepId}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<StepStatusIcon status={step.status} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs font-medium">
|
||||
{step.stepId}
|
||||
</span>
|
||||
<StepStatusBadge status={step.status} />
|
||||
{duration ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{Object.keys(step.output).length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-2xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Output
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{JSON.stringify(step.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{step.error ? (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-2xs font-medium uppercase tracking-[0.16em] text-red-400">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-lg bg-red-500/10 px-3 py-2 font-mono text-xs text-red-400">
|
||||
{step.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingApproval ? (
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-2xs font-medium uppercase tracking-[0.16em] text-amber-600">
|
||||
Pending approval
|
||||
</p>
|
||||
<WorkflowApprovalCard approval={pendingApproval} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsView
|
||||
channels={channels}
|
||||
onCloseWorkflow={onCloseWorkflow}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
editor={editor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onDuplicateWorkflow={onDuplicateWorkflow}
|
||||
onEditWorkflow={onEditWorkflow}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -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<DialogState>({
|
||||
mode: "closed",
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(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 (
|
||||
<div
|
||||
@@ -251,20 +250,11 @@ export function WorkflowsView({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
selectedWorkflowId
|
||||
? "grid grid-cols-1 gap-3 xl:grid-cols-2"
|
||||
: "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
}
|
||||
>
|
||||
<CreateWorkflowCard
|
||||
onClick={() => setDialogState({ mode: "create" })}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<CreateWorkflowCard onClick={onCreateWorkflow} />
|
||||
{allWorkflows.map(({ workflow, channelName }) => (
|
||||
<WorkflowCard
|
||||
channelName={channelName}
|
||||
isActive={selectedWorkflowId === workflow.id}
|
||||
isTogglingEnabled={
|
||||
toggleEnabledMutation.isPending &&
|
||||
toggleEnabledMutation.variables?.id === workflow.id
|
||||
@@ -283,29 +273,23 @@ export function WorkflowsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWorkflowId ? (
|
||||
<div className="w-[400px] shrink-0">
|
||||
<WorkflowDetailPanel
|
||||
key={selectedWorkflowId}
|
||||
onClose={onCloseWorkflow}
|
||||
onEdit={handleEdit}
|
||||
workflowId={selectedWorkflowId}
|
||||
/>
|
||||
</div>
|
||||
{editor && canOpenEditor ? (
|
||||
<WorkflowDialog
|
||||
channels={memberChannels}
|
||||
key={
|
||||
editor.mode === "create"
|
||||
? editor.mode
|
||||
: `${editor.mode}:${editor.workflowId}`
|
||||
}
|
||||
mode={editor.mode}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCloseEditor();
|
||||
}}
|
||||
open
|
||||
workflow={editorWorkflow}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorkflowDialog
|
||||
channels={memberChannels}
|
||||
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
open={dialogState.mode !== "closed"}
|
||||
workflow={
|
||||
dialogState.mode === "edit" || dialogState.mode === "duplicate"
|
||||
? dialogState.workflow
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkflowDeleteDialog
|
||||
onConfirm={handleConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Reference in New Issue
Block a user