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

View File

@@ -4,6 +4,7 @@ import { NavLink, useSearchParams } from "react-router-dom";
type Option = { type Option = {
label: string; label: string;
to: string; to: string;
icon?: React.ReactNode;
}; };
type Props = { type Props = {
@@ -23,13 +24,18 @@ function SwitchBarNavigation({ options }: Props) {
key={option.to} key={option.to}
className={({ isActive }) => { className={({ isActive }) => {
return cn( 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, "bg-slate-700": isActive,
}, },
); );
}} }}
> >
{option.icon && (
<span className="mr-1 flex items-center justify-center">
{option.icon}
</span>
)}
{option.label} {option.label}
</NavLink> </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 { getClient } from "@/api/AxiosClient";
import { ProxyLocation, Status } from "@/api/types"; import { ProxyLocation, Status } from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { SwitchBarNavigation } from "@/components/SwitchBarNavigation"; import {
SwitchBarNavigation,
type SwitchBarNavigationOption,
} from "@/components/SwitchBarNavigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -19,6 +22,7 @@ import { useApiCredential } from "@/hooks/useApiCredential";
import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { apiBaseUrl } from "@/util/env"; import { apiBaseUrl } from "@/util/env";
import { import {
CodeIcon,
FileIcon, FileIcon,
Pencil2Icon, Pencil2Icon,
PlayIcon, PlayIcon,
@@ -61,6 +65,8 @@ function WorkflowRun() {
isFetched, isFetched,
} = useWorkflowRunQuery(); } = useWorkflowRunQuery();
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery(); const { data: workflowRunTimeline } = useWorkflowRunTimelineQuery();
const cancelWorkflowMutation = useMutation({ const cancelWorkflowMutation = useMutation({
@@ -208,7 +214,7 @@ function WorkflowRun() {
webhookFailureReasonData) && webhookFailureReasonData) &&
workflowRun.status === Status.Completed; workflowRun.status === Status.Completed;
const switchBarOptions = [ const switchBarOptions: SwitchBarNavigationOption[] = [
{ {
label: "Overview", label: "Overview",
to: "overview", to: "overview",
@@ -227,10 +233,18 @@ function WorkflowRun() {
}, },
]; ];
const isGeneratingCode = !isFinalized && workflow?.generate_script === true;
if (!hasScript) { if (!hasScript) {
switchBarOptions.push({ switchBarOptions.push({
label: "Code", label: "Code",
to: "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 { Flippable } from "@/components/Flippable";
import { useRerender } from "@/hooks/useRerender"; import { useRerender } from "@/hooks/useRerender";
import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { useUiStore } from "@/store/UiStore";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
@@ -78,11 +79,31 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
runSequentially: data.withWorkflowSettings ? data.runSequentially : false, runSequentially: data.withWorkflowSettings ? data.runSequentially : false,
}); });
const { highlightGenerateCodeToggle, setHighlightGenerateCodeToggle } =
useUiStore();
const [facing, setFacing] = useState<"front" | "back">("front"); const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore(); const blockScriptStore = useBlockScriptStore();
const script = blockScriptStore.scripts.__start_block__; const script = blockScriptStore.scripts.__start_block__;
const rerender = useRerender({ prefix: "accordion" }); const rerender = useRerender({ prefix: "accordion" });
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); 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(() => { useEffect(() => {
setFacing(data.showCode ? "back" : "front"); setFacing(data.showCode ? "back" : "front");
@@ -137,6 +158,10 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
} }
} }
const defaultWorkflowSettings = expandWorkflowSettings
? "settings"
: undefined;
if (data.withWorkflowSettings) { if (data.withWorkflowSettings) {
return ( return (
<Flippable facing={facing} preserveFrontsideHeight={true}> <Flippable facing={facing} preserveFrontsideHeight={true}>
@@ -159,7 +184,12 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
onValueChange={() => rerender.bump()} value={defaultWorkflowSettings}
defaultValue={defaultWorkflowSettings}
onValueChange={(value) => {
setExpandWorkflowSettings(value === "settings");
rerender.bump();
}}
> >
<AccordionItem value="settings" className="mt-4 border-b-0"> <AccordionItem value="settings" className="mt-4 border-b-0">
<AccordionTrigger className="py-2"> <AccordionTrigger className="py-2">
@@ -207,10 +237,16 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div
<Label>Run Cached Code</Label> 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." /> <HelpTooltip content="If code has been cached, run the workflow using code for faster execution." />
<Switch <Switch
disabled={inputs.useScriptCache === true} // TODO(jdo/always-generate): remove
className="ml-auto" className="ml-auto"
checked={inputs.useScriptCache} checked={inputs.useScriptCache}
onCheckedChange={(value) => { onCheckedChange={(value) => {

View File

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

View File

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