Improve workflow run loop/conditional timeline UX follow-up (#SKY-7367) (#4782)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan
2026-02-18 09:35:25 -05:00
committed by GitHub
parent 1dff9ac921
commit 4db25ec04f
7 changed files with 554 additions and 276 deletions

View File

@@ -49,38 +49,46 @@ const artifactApiClient = axios.create({
baseURL: artifactApiBaseUrl, baseURL: artifactApiBaseUrl,
}); });
const clients = [client, v2Client, clientSansApiV1] as const;
function setHeaderForAllClients(header: string, value: string) {
clients.forEach((instance) => {
instance.defaults.headers.common[header] = value;
});
}
function removeHeaderForAllClients(header: string) {
clients.forEach((instance) => {
// Axios stores headers at both `common` (shared across methods) and the
// top-level defaults object (can be set by initial config spread). Delete
// from both locations to ensure the header is fully removed.
delete instance.defaults.headers.common[header];
delete (instance.defaults.headers as Record<string, unknown>)[header];
});
}
export function setAuthorizationHeader(token: string) { export function setAuthorizationHeader(token: string) {
client.defaults.headers.common["Authorization"] = `Bearer ${token}`; setHeaderForAllClients("Authorization", `Bearer ${token}`);
v2Client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
clientSansApiV1.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} }
export function removeAuthorizationHeader() { export function removeAuthorizationHeader() {
if (client.defaults.headers.common["Authorization"]) { removeHeaderForAllClients("Authorization");
delete client.defaults.headers.common["Authorization"];
delete v2Client.defaults.headers.common["Authorization"];
delete clientSansApiV1.defaults.headers.common["Authorization"];
}
} }
export function setApiKeyHeader(apiKey: string) { export function setApiKeyHeader(apiKey: string) {
persistRuntimeApiKey(apiKey); persistRuntimeApiKey(apiKey);
client.defaults.headers.common["X-API-Key"] = apiKey; setHeaderForAllClients("X-API-Key", apiKey);
v2Client.defaults.headers.common["X-API-Key"] = apiKey;
clientSansApiV1.defaults.headers.common["X-API-Key"] = apiKey;
} }
export function removeApiKeyHeader() { export function removeApiKeyHeader({
clearRuntimeApiKey(); clearStoredKey = true,
if (client.defaults.headers.common["X-API-Key"]) { }: {
delete client.defaults.headers.common["X-API-Key"]; clearStoredKey?: boolean;
} } = {}) {
if (v2Client.defaults.headers.common["X-API-Key"]) { if (clearStoredKey) {
delete v2Client.defaults.headers.common["X-API-Key"]; clearRuntimeApiKey();
}
if (clientSansApiV1.defaults.headers.common["X-API-Key"]) {
delete clientSansApiV1.defaults.headers.common["X-API-Key"];
} }
removeHeaderForAllClients("X-API-Key");
} }
async function getClient( async function getClient(
@@ -101,17 +109,24 @@ async function getClient(
} }
}; };
if (credentialGetter) { // Both auth headers are sent intentionally: Authorization (Bearer token from
removeApiKeyHeader(); // the credential getter, e.g. Clerk) is used for user-session auth, while
// X-API-Key is used for org-level API key auth. The backend accepts either
const credential = await credentialGetter(); // and gives precedence to the API key when both are present. Sending both
// ensures requests succeed regardless of which auth method the org uses.
if (!credential) { const credential = credentialGetter ? await credentialGetter() : null;
console.warn("No credential found"); if (credential) {
return get();
}
setAuthorizationHeader(credential); setAuthorizationHeader(credential);
} else {
removeAuthorizationHeader();
}
const apiKey = getRuntimeApiKey();
if (apiKey) {
setHeaderForAllClients("X-API-Key", apiKey);
} else {
// Avoid mutating persisted keys here - just control request headers.
removeApiKeyHeader({ clearStoredKey: false });
} }
return get(); return get();

View File

@@ -114,11 +114,11 @@ export async function getSseClient(
if (authToken) { if (authToken) {
requestHeaders.Authorization = `Bearer ${authToken}`; requestHeaders.Authorization = `Bearer ${authToken}`;
} else { }
const apiKey = getRuntimeApiKey();
if (apiKey) { const apiKey = getRuntimeApiKey();
requestHeaders["X-API-Key"] = apiKey; if (apiKey) {
} requestHeaders["X-API-Key"] = apiKey;
} }
return { return {

View File

@@ -1,23 +1,23 @@
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { statusIsFinalized } from "@/routes/tasks/types"; import { statusIsFinalized } from "@/routes/tasks/types";
import { cn } from "@/util/utils";
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
import { import {
isBlockItem, isBlockItem,
isObserverThought, isObserverThought,
isTaskVariantBlockItem,
isThoughtItem, isThoughtItem,
ObserverThought, ObserverThought,
WorkflowRunBlock, WorkflowRunBlock,
} from "../types/workflowRunTypes"; } from "../types/workflowRunTypes";
import { countActions } from "../utils";
import { ThoughtCard } from "@/routes/workflows/workflowRun/ThoughtCard"; import { ThoughtCard } from "@/routes/workflows/workflowRun/ThoughtCard";
import { import {
ActionItem, ActionItem,
WorkflowRunOverviewActiveElement, WorkflowRunOverviewActiveElement,
} from "@/routes/workflows/workflowRun/WorkflowRunOverview"; } from "@/routes/workflows/workflowRun/WorkflowRunOverview";
import { WorkflowRunTimelineBlockItem } from "@/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem"; import { WorkflowRunTimelineBlockItem } from "@/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem";
import { cn } from "@/util/utils";
type Props = { type Props = {
activeItem: WorkflowRunOverviewActiveElement; activeItem: WorkflowRunOverviewActiveElement;
@@ -48,12 +48,7 @@ function DebuggerRunTimeline({
const workflowRunIsFinalized = statusIsFinalized(workflowRun); const workflowRunIsFinalized = statusIsFinalized(workflowRun);
const numberOfActions = workflowRunTimeline.reduce((total, current) => { const numberOfActions = countActions(workflowRunTimeline);
if (isTaskVariantBlockItem(current)) {
return total + current.block!.actions!.length;
}
return total + 0;
}, 0);
const firstActionOrThoughtIsPending = const firstActionOrThoughtIsPending =
!workflowRunIsFinalized && workflowRunTimeline.length === 0; !workflowRunIsFinalized && workflowRunTimeline.length === 0;

View File

@@ -1,6 +1,11 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { WorkflowParameter } from "./types/workflowTypes"; import type { WorkflowParameter } from "./types/workflowTypes";
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import {
isBlockItem,
isTaskVariantBlockItem,
WorkflowRunTimelineItem,
} from "./types/workflowRunTypes";
type Location = ReturnType<typeof useLocation>; type Location = ReturnType<typeof useLocation>;
@@ -73,6 +78,20 @@ export const formatDuration = (duration: Duration): string => {
} }
}; };
export function countActions(items: Array<WorkflowRunTimelineItem>): number {
return items.reduce((total, item) => {
if (!isBlockItem(item)) {
return total;
}
const blockActionCount = isTaskVariantBlockItem(item)
? item.block.actions?.length ?? 0
: 0;
return total + blockActionCount + countActions(item.children);
}, 0);
}
export const getOrderedBlockLabels = (workflow?: WorkflowApiResponse) => { export const getOrderedBlockLabels = (workflow?: WorkflowApiResponse) => {
if (!workflow) { if (!workflow) {
return []; return [];

View File

@@ -8,11 +8,11 @@ import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuer
import { import {
isBlockItem, isBlockItem,
isObserverThought, isObserverThought,
isTaskVariantBlockItem,
isThoughtItem, isThoughtItem,
ObserverThought, ObserverThought,
WorkflowRunBlock, WorkflowRunBlock,
} from "../types/workflowRunTypes"; } from "../types/workflowRunTypes";
import { countActions } from "../utils";
import { ThoughtCard } from "./ThoughtCard"; import { ThoughtCard } from "./ThoughtCard";
import { import {
ActionItem, ActionItem,
@@ -56,12 +56,7 @@ function WorkflowRunTimeline({
const finallyBlockLabel = const finallyBlockLabel =
workflowRun.workflow?.workflow_definition?.finally_block_label ?? null; workflowRun.workflow?.workflow_definition?.finally_block_label ?? null;
const numberOfActions = workflowRunTimeline.reduce((total, current) => { const numberOfActions = countActions(workflowRunTimeline);
if (isTaskVariantBlockItem(current)) {
return total + current.block!.actions!.length;
}
return total + 0;
}, 0);
return ( return (
<div className="min-w-0 space-y-4 rounded bg-slate-elevation1 p-4"> <div className="min-w-0 space-y-4 rounded bg-slate-elevation1 p-4">

View File

@@ -1,51 +1,190 @@
import { import {
CheckCircledIcon, CheckCircledIcon,
ChevronDownIcon,
ChevronRightIcon,
CrossCircledIcon, CrossCircledIcon,
CubeIcon, CubeIcon,
ExternalLinkIcon, ExternalLinkIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useCallback } from "react"; import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Status } from "@/api/types"; import { Status } from "@/api/types";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { formatDuration, toDuration } from "@/routes/workflows/utils"; import { formatDuration, toDuration } from "@/routes/workflows/utils";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { workflowBlockTitle } from "../editor/nodes/types"; import { workflowBlockTitle } from "../editor/nodes/types";
import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
import { import {
hasEvaluations,
isAction, isAction,
isBlockItem, isBlockItem,
isObserverThought, isObserverThought,
isThoughtItem, isThoughtItem,
isWorkflowRunBlock, isWorkflowRunBlock,
hasEvaluations, ObserverThought,
WorkflowRunBlock, WorkflowRunBlock,
WorkflowRunTimelineItem, WorkflowRunTimelineItem,
} from "../types/workflowRunTypes"; } from "../types/workflowRunTypes";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { ActionCard } from "./ActionCard"; import { ActionCard } from "./ActionCard";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
import { import {
ActionItem, ActionItem,
WorkflowRunOverviewActiveElement, WorkflowRunOverviewActiveElement,
} from "./WorkflowRunOverview"; } from "./WorkflowRunOverview";
import { ThoughtCard } from "./ThoughtCard"; import { ThoughtCard } from "./ThoughtCard";
import { ObserverThought } from "../types/workflowRunTypes";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
type Props = { type Props = {
activeItem: WorkflowRunOverviewActiveElement; activeItem: WorkflowRunOverviewActiveElement;
block: WorkflowRunBlock; block: WorkflowRunBlock;
subItems: Array<WorkflowRunTimelineItem>; subItems: Array<WorkflowRunTimelineItem>;
depth?: number;
onBlockItemClick: (block: WorkflowRunBlock) => void; onBlockItemClick: (block: WorkflowRunBlock) => void;
onActionClick: (action: ActionItem) => void; onActionClick: (action: ActionItem) => void;
onThoughtCardClick: (thought: ObserverThought) => void; onThoughtCardClick: (thought: ObserverThought) => void;
finallyBlockLabel?: string | null; finallyBlockLabel?: string | null;
}; };
type LoopIterationGroup = {
index: number | null;
currentValue: string | null;
items: Array<WorkflowRunTimelineItem>;
};
function stringifyTimelineValue(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function truncateValue(value: string, maxLength = 120): string {
const collapsed = value.replace(/\s+/g, " ").trim();
if (collapsed.length <= maxLength) {
return collapsed;
}
return `${collapsed.slice(0, maxLength - 3)}...`;
}
function getLoopIterationGroups(
items: Array<WorkflowRunTimelineItem>,
): Array<LoopIterationGroup> {
const groupsByKey = new Map<string, LoopIterationGroup>();
items.forEach((item) => {
const currentIndex = isBlockItem(item) ? item.block.current_index : null;
const currentValue = isBlockItem(item) ? item.block.current_value : null;
const groupKey =
currentIndex === null ? "unknown" : `index-${currentIndex}`;
if (!groupsByKey.has(groupKey)) {
groupsByKey.set(groupKey, {
index: currentIndex,
currentValue,
items: [],
});
}
const group = groupsByKey.get(groupKey)!;
if (!group.currentValue && currentValue) {
group.currentValue = currentValue;
}
group.items.push(item);
});
return Array.from(groupsByKey.values()).sort((left, right) => {
if (left.index === null && right.index === null) {
return 0;
}
if (left.index === null) {
return 1;
}
if (right.index === null) {
return -1;
}
return right.index - left.index;
});
}
type TimelineSubItemsProps = {
items: Array<WorkflowRunTimelineItem>;
activeItem: WorkflowRunOverviewActiveElement;
depth: number;
onBlockItemClick: (block: WorkflowRunBlock) => void;
onActionClick: (action: ActionItem) => void;
onThoughtCardClick: (thought: ObserverThought) => void;
finallyBlockLabel?: string | null;
};
function TimelineSubItems({
items,
activeItem,
depth,
onBlockItemClick,
onActionClick,
onThoughtCardClick,
finallyBlockLabel,
}: TimelineSubItemsProps) {
return (
<div className="space-y-3">
{items.map((item) => {
if (isBlockItem(item)) {
return (
<WorkflowRunTimelineBlockItem
key={item.block.workflow_run_block_id}
subItems={item.children}
activeItem={activeItem}
block={item.block}
depth={depth}
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
);
}
if (isThoughtItem(item)) {
return (
<ThoughtCard
key={item.thought.thought_id}
active={
isObserverThought(activeItem) &&
activeItem.thought_id === item.thought.thought_id
}
onClick={onThoughtCardClick}
thought={item.thought}
/>
);
}
})}
</div>
);
}
function WorkflowRunTimelineBlockItem({ function WorkflowRunTimelineBlockItem({
activeItem, activeItem,
block, block,
subItems, subItems,
depth = 0,
onBlockItemClick, onBlockItemClick,
onActionClick, onActionClick,
onThoughtCardClick, onThoughtCardClick,
@@ -98,241 +237,352 @@ function WorkflowRunTimelineBlockItem({
// NOTE(jdo): want to put this back; await for now // NOTE(jdo): want to put this back; await for now
const showDuration = false as const; const showDuration = false as const;
const hasNestedChildren = subItems.length > 0;
const isLoopBlock = block.block_type === "for_loop";
const isConditionalBlock = block.block_type === "conditional";
const [childrenOpen, setChildrenOpen] = useState(!isConditionalBlock);
const loopIterationGroups = useMemo(
() => getLoopIterationGroups(subItems),
[subItems],
);
const loopValues = Array.isArray(block.loop_values) ? block.loop_values : [];
return ( return (
<div <div className={cn({ "ml-3 border-l border-slate-700 pl-3": depth > 0 })}>
className={cn( <div
"cursor-pointer space-y-4 rounded border border-slate-600 p-4", className={cn(
{ "cursor-pointer space-y-4 rounded border border-slate-600 p-4",
"border-slate-50": {
isWorkflowRunBlock(activeItem) && "border-slate-50":
activeItem.workflow_run_block_id === block.workflow_run_block_id, isWorkflowRunBlock(activeItem) &&
}, activeItem.workflow_run_block_id === block.workflow_run_block_id,
)} },
onClick={(event) => { )}
event.stopPropagation(); onClick={(event) => {
onBlockItemClick(block); event.stopPropagation();
}} onBlockItemClick(block);
ref={refCallback} }}
> ref={refCallback}
<div className="space-y-2"> >
<div className="flex justify-between"> <div className="space-y-2">
<div className="flex gap-3"> <div className="flex justify-between">
<div className="rounded bg-slate-elevation5 p-2"> <div className="flex gap-3">
<WorkflowBlockIcon <div className="rounded bg-slate-elevation5 p-2">
workflowBlockType={block.block_type} <WorkflowBlockIcon
className="size-6" workflowBlockType={block.block_type}
/> className="size-6"
</div> />
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm"> <span className="text-sm">
{workflowBlockTitle[block.block_type]} {workflowBlockTitle[block.block_type]}
</span>
<span className="flex gap-2 text-xs text-slate-400">
{block.label}
</span>
{isFinallyBlock && (
<span className="w-fit rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-black">
Execute on any outcome
</span> </span>
)} <span className="flex gap-2 text-xs text-slate-400">
</div> {block.label}
</div> </span>
<div className="flex gap-2"> {isFinallyBlock && (
{showFailureIndicator && ( <span className="w-fit rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-black">
<div className="self-start rounded bg-slate-elevation5 px-2 py-1"> Execute on any outcome
<CrossCircledIcon className="size-4 text-destructive" /> </span>
</div>
)}
{showSuccessIndicator && (
<div className="self-start rounded bg-slate-elevation5 px-2 py-1">
<CheckCircledIcon className="size-4 text-success" />
</div>
)}
<div className="flex flex-col items-end gap-[1px]">
<div className="flex gap-1 self-start rounded bg-slate-elevation5 px-2 py-1">
{showDiagnosticLink ? (
<Link
to={`/tasks/${block.task_id}/diagnostics`}
onClick={(event) => event.stopPropagation()}
>
<div className="flex gap-1">
<ExternalLinkIcon className="size-4" />
<span className="text-xs">Diagnostics</span>
</div>
</Link>
) : (
<>
<CubeIcon className="size-4" />
<span className="text-xs">Block</span>
</>
)} )}
</div> </div>
{duration && showDuration && ( </div>
<div className="pr-[5px] text-xs text-[#00ecff]"> <div className="flex gap-2">
{duration} {showFailureIndicator && (
<div className="self-start rounded bg-slate-elevation5 px-2 py-1">
<CrossCircledIcon className="size-4 text-destructive" />
</div>
)}
{showSuccessIndicator && (
<div className="self-start rounded bg-slate-elevation5 px-2 py-1">
<CheckCircledIcon className="size-4 text-success" />
</div>
)}
<div className="flex flex-col items-end gap-[1px]">
<div className="flex gap-1 self-start rounded bg-slate-elevation5 px-2 py-1">
{showDiagnosticLink ? (
<Link
to={`/tasks/${block.task_id}/diagnostics`}
onClick={(event) => event.stopPropagation()}
>
<div className="flex gap-1">
<ExternalLinkIcon className="size-4" />
<span className="text-xs">Diagnostics</span>
</div>
</Link>
) : (
<>
<CubeIcon className="size-4" />
<span className="text-xs">Block</span>
</>
)}
</div>
{duration && showDuration && (
<div className="pr-[5px] text-xs text-[#00ecff]">
{duration}
</div>
)}
</div>
</div>
</div>
{block.description ? (
<div className="text-xs text-slate-400">{block.description}</div>
) : null}
{isLoopBlock && (
<div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
<div className="text-slate-300">
Iterable values:{" "}
<span className="font-medium text-slate-200">
{loopValues.length}
</span>
</div>
{loopValues.length > 0 && (
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
{loopValues.map((value, index) => (
<div key={index} className="text-slate-400">
<span className="mr-1 text-slate-500">[{index}]</span>
<code className="rounded bg-slate-elevation1 px-1 py-0.5 font-mono text-slate-300">
{truncateValue(stringifyTimelineValue(value), 120)}
</code>
</div>
))}
</div> </div>
)} )}
</div> </div>
</div> )}
</div> {block.block_type === "conditional" && block.executed_branch_id && (
{block.description ? ( <div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
<div className="text-xs text-slate-400">{block.description}</div> {hasEvaluations(block.output) && block.output.evaluations ? (
) : null} // New format: show all branch evaluations
{block.block_type === "conditional" && block.executed_branch_id && ( <div className="space-y-2">
<div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs"> {block.output.evaluations.map((evaluation, index) => (
{hasEvaluations(block.output) && block.output.evaluations ? ( <div
// New format: show all branch evaluations key={evaluation.branch_id || index}
<div className="space-y-2"> className={cn(
{block.output.evaluations.map((evaluation, index) => ( "rounded border px-2 py-1.5",
<div evaluation.is_matched
key={evaluation.branch_id || index} ? "border-success/50 bg-success/10"
className={cn( : "border-slate-600 bg-slate-elevation3",
"rounded border px-2 py-1.5", )}
evaluation.is_matched >
? "border-success/50 bg-success/10" {evaluation.is_default ? (
: "border-slate-600 bg-slate-elevation3", <div className="text-slate-300">
)} <span className="font-medium">Default branch</span>
>
{evaluation.is_default ? (
<div className="text-slate-300">
<span className="font-medium">Default branch</span>
{evaluation.is_matched && (
<span className="ml-2 text-success"> Matched</span>
)}
</div>
) : (
<div className="space-y-1">
<div className="text-slate-400">
<code className="rounded bg-slate-elevation1 px-1 py-0.5 font-mono text-slate-300">
{evaluation.original_expression}
</code>
</div>
{evaluation.rendered_expression &&
evaluation.rendered_expression !==
evaluation.original_expression && (
<div className="text-slate-400">
rendered to{" "}
<code className="rounded bg-slate-elevation1 px-1 py-0.5 font-mono text-slate-200">
{evaluation.rendered_expression}
</code>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-slate-400">evaluated to</span>
<span
className={cn(
"font-medium",
evaluation.result
? "text-success"
: "text-red-400",
)}
>
{evaluation.result ? "True" : "False"}
</span>
{evaluation.is_matched && ( {evaluation.is_matched && (
<span className="text-success"> Matched</span> <span className="ml-2 text-success"> Matched</span>
)} )}
</div> </div>
</div> ) : (
)} <div className="space-y-1">
{evaluation.is_matched && evaluation.next_block_label && ( <div className="text-slate-400">
<div className="mt-1 text-slate-400"> <code className="rounded bg-slate-elevation1 px-1 py-0.5 font-mono text-slate-300">
Executing next block:{" "} {evaluation.original_expression}
<span className="font-medium text-slate-300"> </code>
{evaluation.next_block_label} </div>
</span> {evaluation.rendered_expression &&
</div> evaluation.rendered_expression !==
)} evaluation.original_expression && (
<div className="text-slate-400">
-&gt; rendered to{" "}
<code className="rounded bg-slate-elevation1 px-1 py-0.5 font-mono text-slate-200">
{evaluation.rendered_expression}
</code>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-slate-400">evaluated to</span>
<span
className={cn(
"font-medium",
evaluation.result
? "text-success"
: "text-red-400",
)}
>
{evaluation.result ? "True" : "False"}
</span>
{evaluation.is_matched && (
<span className="text-success"> Matched</span>
)}
</div>
</div>
)}
{evaluation.is_matched && evaluation.next_block_label && (
<div className="mt-1 text-slate-400">
-&gt; Executing next block:{" "}
<span className="font-medium text-slate-300">
{evaluation.next_block_label}
</span>
</div>
)}
</div>
))}
</div>
) : (
// Fallback: old format without evaluations array
<>
{block.executed_branch_expression !== null &&
block.executed_branch_expression !== undefined ? (
<div className="text-slate-300">
Condition{" "}
<code className="rounded bg-slate-elevation3 px-1.5 py-0.5 font-mono text-slate-200">
{block.executed_branch_expression}
</code>{" "}
evaluated to{" "}
<span className="font-medium text-success">True</span>
</div>
) : (
<div className="text-slate-300">
No conditions matched, executing default branch
</div>
)}
{block.executed_branch_next_block && (
<div className="text-slate-400">
-&gt; Executing next block:{" "}
<span className="font-medium text-slate-300">
{block.executed_branch_next_block}
</span>
</div>
)}
</>
)}
</div>
)}
</div>
{block.block_type === "human_interaction" && (
<WorkflowRunHumanInteraction workflowRunBlock={block} />
)}
{actions.map((action, index) => {
return (
<ActionCard
key={action.action_id}
action={action}
active={
isAction(activeItem) &&
activeItem.action_id === action.action_id
}
index={actions.length - index}
onClick={(event) => {
event.stopPropagation();
const actionItem: ActionItem = {
block,
action,
};
onActionClick(actionItem);
}}
/>
);
})}
{hasNestedChildren && isLoopBlock && (
<div className="space-y-2">
{loopIterationGroups.map((group, groupIndex) => {
const loopValueFromIterable =
group.index !== null ? loopValues[group.index] ?? null : null;
const iterationNumber =
group.index !== null
? group.index + 1
: loopIterationGroups.length - groupIndex;
const currentValuePreview = truncateValue(
stringifyTimelineValue(
loopValueFromIterable ?? group.currentValue,
),
140,
);
return (
<Collapsible
key={`${group.index ?? "unknown"}-${groupIndex}`}
defaultOpen={false}
>
<div className="rounded border border-slate-700 bg-slate-elevation4">
<CollapsibleTrigger asChild>
<button
className="group flex w-full items-center justify-between gap-2 px-2 py-1 text-left"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-1.5">
<ChevronRightIcon className="size-4 text-slate-300 transition-transform group-data-[state=open]:rotate-90" />
<span className="text-xs text-slate-200">{`Iteration ${iterationNumber}`}</span>
</div>
<code className="max-w-[70%] truncate rounded bg-slate-elevation1 px-1 py-0.5 text-[11px] text-slate-300">
current_value: {currentValuePreview}
</code>
</button>
</CollapsibleTrigger>
</div> </div>
))} <CollapsibleContent className="px-2 pb-2 pt-2">
</div> <TimelineSubItems
) : ( items={group.items}
// Fallback: old format without evaluations array activeItem={activeItem}
<> depth={depth + 1}
{block.executed_branch_expression !== null && onActionClick={onActionClick}
block.executed_branch_expression !== undefined ? ( onBlockItemClick={onBlockItemClick}
<div className="text-slate-300"> onThoughtCardClick={onThoughtCardClick}
Condition{" "} finallyBlockLabel={finallyBlockLabel}
<code className="rounded bg-slate-elevation3 px-1.5 py-0.5 font-mono text-slate-200"> />
{block.executed_branch_expression} </CollapsibleContent>
</code>{" "} </Collapsible>
evaluated to{" "} );
<span className="font-medium text-success">True</span> })}
</div>
) : (
<div className="text-slate-300">
No conditions matched, executing default branch
</div>
)}
{block.executed_branch_next_block && (
<div className="text-slate-400">
Executing next block:{" "}
<span className="font-medium text-slate-300">
{block.executed_branch_next_block}
</span>
</div>
)}
</>
)}
</div> </div>
)} )}
</div>
{block.block_type === "human_interaction" && ( {hasNestedChildren && isConditionalBlock && (
<WorkflowRunHumanInteraction workflowRunBlock={block} /> <Collapsible open={childrenOpen} onOpenChange={setChildrenOpen}>
)} <div className="rounded border border-slate-700 bg-slate-elevation4 px-2 py-1.5">
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center justify-between gap-2 text-left"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-1.5 text-xs text-slate-200">
{childrenOpen ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
<span>{`Executed branch blocks (${subItems.length})`}</span>
</div>
{block.executed_branch_next_block && (
<span className="text-[11px] text-slate-400">
next: {block.executed_branch_next_block}
</span>
)}
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2 pt-2">
<TimelineSubItems
items={subItems}
activeItem={activeItem}
depth={depth + 1}
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
</CollapsibleContent>
</Collapsible>
)}
{actions.map((action, index) => { {hasNestedChildren && !isLoopBlock && !isConditionalBlock && (
return ( <TimelineSubItems
<ActionCard items={subItems}
key={action.action_id} activeItem={activeItem}
action={action} depth={depth + 1}
active={ onActionClick={onActionClick}
isAction(activeItem) && activeItem.action_id === action.action_id onBlockItemClick={onBlockItemClick}
} onThoughtCardClick={onThoughtCardClick}
index={actions.length - index} finallyBlockLabel={finallyBlockLabel}
onClick={(event) => {
event.stopPropagation();
const actionItem: ActionItem = {
block,
action,
};
onActionClick(actionItem);
}}
/> />
); )}
})} </div>
{subItems.map((item) => {
if (isBlockItem(item)) {
return (
<WorkflowRunTimelineBlockItem
key={item.block.workflow_run_block_id}
subItems={item.children}
activeItem={activeItem}
block={item.block}
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
);
}
if (isThoughtItem(item)) {
return (
<ThoughtCard
key={item.thought.thought_id}
active={
isObserverThought(activeItem) &&
activeItem.thought_id === item.thought.thought_id
}
onClick={onThoughtCardClick}
thought={item.thought}
/>
);
}
})}
</div> </div>
); );
} }

View File

@@ -4440,11 +4440,15 @@ class AgentDB(BaseAlchemyDB):
workflow_run_block.task_id = task_id workflow_run_block.task_id = task_id
if failure_reason: if failure_reason:
workflow_run_block.failure_reason = failure_reason workflow_run_block.failure_reason = failure_reason
if loop_values: # Use `is not None` instead of truthiness checks so that falsy
# values like current_index=0, empty loop_values=[], or
# current_value="" are correctly persisted. Without this,
# the first loop iteration (index 0) loses its metadata.
if loop_values is not None:
workflow_run_block.loop_values = loop_values workflow_run_block.loop_values = loop_values
if current_value: if current_value is not None:
workflow_run_block.current_value = current_value workflow_run_block.current_value = current_value
if current_index: if current_index is not None:
workflow_run_block.current_index = current_index workflow_run_block.current_index = current_index
if recipients: if recipients:
workflow_run_block.recipients = recipients workflow_run_block.recipients = recipients