code caching: enabled/disabled UX for workflow run UI (#3497)

This commit is contained in:
Jonathan Dobson
2025-09-22 08:00:31 -04:00
committed by GitHub
parent 7e73a55046
commit 8cffccfbb3
7 changed files with 234 additions and 71 deletions

View File

@@ -1,9 +1,16 @@
interface AnimatedWaveProps {
text: string;
className?: string;
duration?: string;
waveHeight?: string;
}
export function AnimatedWave({ text, className = "" }: AnimatedWaveProps) {
export function AnimatedWave({
text,
className = "",
duration = "1.3s",
waveHeight = "4px",
}: AnimatedWaveProps) {
const characters = text.split("");
return (
@@ -14,7 +21,7 @@ export function AnimatedWave({ text, className = "" }: AnimatedWaveProps) {
transform: translateY(0px);
}
50% {
transform: translateY(-4px);
transform: translateY(-${waveHeight});
}
}
.animate-wave {
@@ -28,7 +35,7 @@ export function AnimatedWave({ text, className = "" }: AnimatedWaveProps) {
className="animate-wave inline-block"
style={{
animationDelay: `${index * 0.1}s`,
animationDuration: "1.3s",
animationDuration: duration,
animationIterationCount: "infinite",
animationTimingFunction: "ease-in-out",
}}

View File

@@ -4,6 +4,7 @@ import { NavLink, useSearchParams } from "react-router-dom";
type Option = {
label: string;
to: string;
icon?: React.ReactNode;
};
type Props = {
@@ -23,13 +24,18 @@ function SwitchBarNavigation({ options }: Props) {
key={option.to}
className={({ isActive }) => {
return cn(
"cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700",
"flex cursor-pointer items-center justify-center rounded-sm px-3 py-2 text-center hover:bg-slate-700",
{
"bg-slate-700": isActive,
},
);
}}
>
{option.icon && (
<span className="mr-1 flex items-center justify-center">
{option.icon}
</span>
)}
{option.label}
</NavLink>
);
@@ -38,4 +44,4 @@ function SwitchBarNavigation({ options }: Props) {
);
}
export { SwitchBarNavigation };
export { SwitchBarNavigation, type Option as SwitchBarNavigationOption };

View File

@@ -1,7 +1,10 @@
import { getClient } from "@/api/AxiosClient";
import { ProxyLocation, Status } from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { SwitchBarNavigation } from "@/components/SwitchBarNavigation";
import {
SwitchBarNavigation,
type SwitchBarNavigationOption,
} from "@/components/SwitchBarNavigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,6 +22,7 @@ import { useApiCredential } from "@/hooks/useApiCredential";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { apiBaseUrl } from "@/util/env";
import {
CodeIcon,
FileIcon,
Pencil2Icon,
PlayIcon,
@@ -61,6 +65,8 @@ function WorkflowRun() {
isFetched,
} = useWorkflowRunQuery();
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery();
const cancelWorkflowMutation = useMutation({
@@ -208,7 +214,7 @@ function WorkflowRun() {
webhookFailureReasonData) &&
workflowRun.status === Status.Completed;
const switchBarOptions = [
const switchBarOptions: SwitchBarNavigationOption[] = [
{
label: "Overview",
to: "overview",
@@ -227,10 +233,18 @@ function WorkflowRun() {
},
];
const isGeneratingCode = !isFinalized && workflow?.generate_script === true;
if (!hasScript) {
switchBarOptions.push({
label: "Code",
to: "code",
icon:
isFinalized || !isGeneratingCode ? (
<CodeIcon className="inline-block size-5" />
) : (
<ReloadIcon className="inline-block size-5 animate-spin" />
),
});
}

View File

@@ -33,6 +33,7 @@ import {
import { Flippable } from "@/components/Flippable";
import { useRerender } from "@/hooks/useRerender";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { useUiStore } from "@/store/UiStore";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { cn } from "@/util/utils";
@@ -78,11 +79,31 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
runSequentially: data.withWorkflowSettings ? data.runSequentially : false,
});
const { highlightGenerateCodeToggle, setHighlightGenerateCodeToggle } =
useUiStore();
const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore();
const script = blockScriptStore.scripts.__start_block__;
const rerender = useRerender({ prefix: "accordion" });
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
const [expandWorkflowSettings, setExpandWorkflowSettings] = useState(false);
useEffect(() => {
const tm = setTimeout(() => {
if (highlightGenerateCodeToggle) {
setExpandWorkflowSettings(true);
rerender.bump();
setTimeout(() => {
setHighlightGenerateCodeToggle(false);
}, 3000);
}
}, 200);
return () => clearTimeout(tm);
// onMount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setFacing(data.showCode ? "back" : "front");
@@ -137,6 +158,10 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
}
}
const defaultWorkflowSettings = expandWorkflowSettings
? "settings"
: undefined;
if (data.withWorkflowSettings) {
return (
<Flippable facing={facing} preserveFrontsideHeight={true}>
@@ -159,7 +184,12 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<Accordion
type="single"
collapsible
onValueChange={() => rerender.bump()}
value={defaultWorkflowSettings}
defaultValue={defaultWorkflowSettings}
onValueChange={(value) => {
setExpandWorkflowSettings(value === "settings");
rerender.bump();
}}
>
<AccordionItem value="settings" className="mt-4 border-b-0">
<AccordionTrigger className="py-2">
@@ -207,10 +237,16 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<div className="flex flex-col gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Run Cached Code</Label>
<div
className={cn("flex items-center gap-2", {
"animate-pulse rounded-md bg-yellow-600/20":
highlightGenerateCodeToggle,
})}
>
<Label>Generate Code</Label>
<HelpTooltip content="If code has been cached, run the workflow using code for faster execution." />
<Switch
disabled={inputs.useScriptCache === true} // TODO(jdo/always-generate): remove
className="ml-auto"
checked={inputs.useScriptCache}
onCheckedChange={(value) => {

View File

@@ -8,7 +8,7 @@ type Props = {
cacheKeyValue?: string;
workflowPermanentId?: string;
pollIntervalMs?: number;
status?: string;
status?: "pending" | "published";
workflowRunId?: string;
};

View File

@@ -1,3 +1,4 @@
import { ExclamationTriangleIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
@@ -19,6 +20,7 @@ import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import { constructCacheKeyValue } from "@/routes/workflows/editor/utils";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
import { useUiStore } from "@/store/UiStore";
interface Props {
showCacheKeyValueSelector?: boolean;
@@ -44,6 +46,7 @@ function WorkflowRunCode(props?: Props) {
page: 1,
workflowPermanentId,
});
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
const parameters = workflowRun?.parameters;
@@ -52,11 +55,17 @@ function WorkflowRunCode(props?: Props) {
cacheKeyValue,
workflowPermanentId,
pollIntervalMs: !isFinalized ? 3000 : undefined,
status: "pending",
status: isFinalized ? "published" : "pending",
workflowRunId: workflowRun?.workflow_run_id,
});
const orderedBlockLabels = getOrderedBlockLabels(workflow);
const code = getCode(orderedBlockLabels, blockScripts).join("").trim();
const isGeneratingCode = !isFinalized && workflow?.generate_script === true;
const couldBeGeneratingCode =
!isFinalized && workflow?.generate_script !== true;
const { setHighlightGenerateCodeToggle } = useUiStore();
useEffect(() => {
setCacheKeyValue(
@@ -93,7 +102,7 @@ function WorkflowRunCode(props?: Props) {
});
}, [queryClient, workflowRun, workflowPermanentId, cacheKey, cacheKeyValue]);
if (code.length === 0) {
if (code.length === 0 && isFinalized) {
return (
<div className="flex items-center justify-center bg-slate-elevation3 p-8">
No code has been generated yet.
@@ -101,19 +110,6 @@ function WorkflowRunCode(props?: Props) {
);
}
if (!showCacheKeyValueSelector || !cacheKey || cacheKey === "") {
return (
<CodeEditor
className="h-full overflow-y-scroll"
language="python"
value={code}
lineWrap={false}
readOnly
fontSize={10}
/>
);
}
const cacheKeyValueSet = new Set([...(cacheKeyValues?.values ?? [])]);
const cacheKeyValueForWorkflowRun = constructCacheKeyValue({
@@ -127,52 +123,87 @@ function WorkflowRunCode(props?: Props) {
}
return (
<div className="flex h-full w-full flex-col items-end justify-center gap-2">
<div className="flex w-full justify-end gap-4">
<div className="flex items-center justify-around gap-2">
<Label className="w-[7rem]">Code Key Value</Label>
<HelpTooltip
content={
!isFinalized
? "The code key value the generated code is being stored under."
: "Which generated (& cached) code to view."
}
/>
<div className="flex h-full w-full flex-col items-end justify-start gap-2">
{isGeneratingCode && (
<div className="mb-6 flex w-full gap-2 rounded-md border-[1px] border-[slate-300] p-2">
<div className="p6 flex w-full items-center justify-center rounded-l-md bg-slate-elevation5 px-4 py-2 text-sm">
Generating code...
</div>
<div className="p6 flex items-center justify-center rounded-r-md bg-slate-elevation5 px-4 py-2 text-sm">
<ReloadIcon className="size-8 animate-spin" />
</div>
</div>
<Select
disabled={!isFinalized}
value={cacheKeyValue}
onValueChange={(v: string) => setCacheKeyValue(v)}
>
<SelectTrigger className="max-w-[15rem] [&>span]:text-ellipsis">
<SelectValue placeholder="Code Key Value" />
</SelectTrigger>
<SelectContent>
{Array.from(cacheKeyValueSet)
.sort()
.map((value) => {
return (
<SelectItem key={value} value={value}>
{value === cacheKeyValueForWorkflowRun &&
isFinalized === true ? (
<span className="underline">{value}</span>
) : (
value
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<CodeEditor
className="h-full w-full overflow-y-scroll"
language="python"
value={code}
lineWrap={false}
readOnly
fontSize={10}
/>
)}
{couldBeGeneratingCode && (
<div className="mb-6 flex w-full gap-2 rounded-md border-[1px] border-[slate-300] p-2">
<div className="flex w-full items-center justify-center gap-2 rounded-l-md text-sm">
<div className="flex-1 bg-slate-elevation5 p-4">
Code generation disabled for this run. Please enable{" "}
<a
className="underline hover:text-sky-500"
href={`${location.origin}/workflows/${workflowPermanentId}/debug`}
target="_blank"
onClick={() => setHighlightGenerateCodeToggle(true)}
>
Generate Code
</a>{" "}
in your Workflow Settings to have Skyvern generate code.
</div>
</div>
<div className="p6 flex items-center justify-center rounded-r-md bg-slate-elevation5 px-4 py-2 text-sm">
<ExclamationTriangleIcon className="size-8 text-[gold]" />
</div>
</div>
)}
{showCacheKeyValueSelector && cacheKey && cacheKey !== "" && (
<div className="flex w-full justify-end gap-4">
<div className="flex items-center justify-around gap-2">
<Label className="w-[7rem]">Code Key Value</Label>
<HelpTooltip
content={
!isFinalized
? "The code key value the generated code is being stored under."
: "Which generated (& cached) code to view."
}
/>
</div>
<Select
disabled={!isFinalized}
value={cacheKeyValue}
onValueChange={(v: string) => setCacheKeyValue(v)}
>
<SelectTrigger className="max-w-[15rem] [&>span]:text-ellipsis">
<SelectValue placeholder="Code Key Value" />
</SelectTrigger>
<SelectContent>
{Array.from(cacheKeyValueSet)
.sort()
.map((value) => {
return (
<SelectItem key={value} value={value}>
{value === cacheKeyValueForWorkflowRun &&
isFinalized === true ? (
<span className="underline">{value}</span>
) : (
value
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{(isGeneratingCode || (code && code.length > 0)) && (
<CodeEditor
className="h-full w-full overflow-y-scroll"
language="python"
value={code}
lineWrap={false}
readOnly
fontSize={10}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
/**
* UI Store: put UI-only state here, that needs to be shared across components, tabs, and
* potentially browser refreshes.
*/
import { create } from "zustand";
const namespace = "skyvern.ui" as const;
const write = (key: string, value: unknown) => {
try {
const serialized = JSON.stringify(value);
localStorage.setItem(makeKey(key), serialized);
} catch (error) {
console.error("Error writing to localStorage:", error);
}
};
const read = <T>(
key: string,
validator: (v: T) => boolean,
defaultValue: T,
): T => {
try {
const serialized = localStorage.getItem(makeKey(key));
if (serialized === null) {
return defaultValue;
}
const value = JSON.parse(serialized) as T;
if (validator(value)) {
return value;
}
return defaultValue;
} catch (error) {
return defaultValue;
}
};
const makeKey = (name: string) => {
return `${namespace}.${name}`;
};
type UiStore = {
highlightGenerateCodeToggle: boolean;
setHighlightGenerateCodeToggle: (v: boolean) => void;
};
/**
* There's gotta be a way to remove this boilerplate and keep type-safety (no time)...
*/
const useUiStore = create<UiStore>((set) => {
return {
highlightGenerateCodeToggle: read(
makeKey("highlightGenerateCodeToggle"),
(v) => typeof v === "boolean",
false,
),
setHighlightGenerateCodeToggle: (v: boolean) => {
set({ highlightGenerateCodeToggle: v });
write(makeKey("highlightGenerateCodeToggle"), v);
},
};
});
export { useUiStore };