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,
});
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) {
client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
v2Client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
clientSansApiV1.defaults.headers.common["Authorization"] = `Bearer ${token}`;
setHeaderForAllClients("Authorization", `Bearer ${token}`);
}
export function removeAuthorizationHeader() {
if (client.defaults.headers.common["Authorization"]) {
delete client.defaults.headers.common["Authorization"];
delete v2Client.defaults.headers.common["Authorization"];
delete clientSansApiV1.defaults.headers.common["Authorization"];
}
removeHeaderForAllClients("Authorization");
}
export function setApiKeyHeader(apiKey: string) {
persistRuntimeApiKey(apiKey);
client.defaults.headers.common["X-API-Key"] = apiKey;
v2Client.defaults.headers.common["X-API-Key"] = apiKey;
clientSansApiV1.defaults.headers.common["X-API-Key"] = apiKey;
setHeaderForAllClients("X-API-Key", apiKey);
}
export function removeApiKeyHeader() {
clearRuntimeApiKey();
if (client.defaults.headers.common["X-API-Key"]) {
delete client.defaults.headers.common["X-API-Key"];
}
if (v2Client.defaults.headers.common["X-API-Key"]) {
delete v2Client.defaults.headers.common["X-API-Key"];
}
if (clientSansApiV1.defaults.headers.common["X-API-Key"]) {
delete clientSansApiV1.defaults.headers.common["X-API-Key"];
export function removeApiKeyHeader({
clearStoredKey = true,
}: {
clearStoredKey?: boolean;
} = {}) {
if (clearStoredKey) {
clearRuntimeApiKey();
}
removeHeaderForAllClients("X-API-Key");
}
async function getClient(
@@ -101,17 +109,24 @@ async function getClient(
}
};
if (credentialGetter) {
removeApiKeyHeader();
const credential = await credentialGetter();
if (!credential) {
console.warn("No credential found");
return get();
}
// Both auth headers are sent intentionally: Authorization (Bearer token from
// 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
// and gives precedence to the API key when both are present. Sending both
// ensures requests succeed regardless of which auth method the org uses.
const credential = credentialGetter ? await credentialGetter() : null;
if (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();

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { useLocation } from "react-router-dom";
import type { WorkflowParameter } from "./types/workflowTypes";
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import {
isBlockItem,
isTaskVariantBlockItem,
WorkflowRunTimelineItem,
} from "./types/workflowRunTypes";
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) => {
if (!workflow) {
return [];

View File

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

View File

@@ -1,51 +1,190 @@
import {
CheckCircledIcon,
ChevronDownIcon,
ChevronRightIcon,
CrossCircledIcon,
CubeIcon,
ExternalLinkIcon,
} from "@radix-ui/react-icons";
import { useCallback } from "react";
import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Status } from "@/api/types";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { formatDuration, toDuration } from "@/routes/workflows/utils";
import { cn } from "@/util/utils";
import { workflowBlockTitle } from "../editor/nodes/types";
import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
import {
hasEvaluations,
isAction,
isBlockItem,
isObserverThought,
isThoughtItem,
isWorkflowRunBlock,
hasEvaluations,
ObserverThought,
WorkflowRunBlock,
WorkflowRunTimelineItem,
} from "../types/workflowRunTypes";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { ActionCard } from "./ActionCard";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
import {
ActionItem,
WorkflowRunOverviewActiveElement,
} from "./WorkflowRunOverview";
import { ThoughtCard } from "./ThoughtCard";
import { ObserverThought } from "../types/workflowRunTypes";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { WorkflowRunHumanInteraction } from "./WorkflowRunHumanInteraction";
type Props = {
activeItem: WorkflowRunOverviewActiveElement;
block: WorkflowRunBlock;
subItems: Array<WorkflowRunTimelineItem>;
depth?: number;
onBlockItemClick: (block: WorkflowRunBlock) => void;
onActionClick: (action: ActionItem) => void;
onThoughtCardClick: (thought: ObserverThought) => void;
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({
activeItem,
block,
subItems,
depth = 0,
onBlockItemClick,
onActionClick,
onThoughtCardClick,
@@ -98,241 +237,352 @@ function WorkflowRunTimelineBlockItem({
// NOTE(jdo): want to put this back; await for now
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 (
<div
className={cn(
"cursor-pointer space-y-4 rounded border border-slate-600 p-4",
{
"border-slate-50":
isWorkflowRunBlock(activeItem) &&
activeItem.workflow_run_block_id === block.workflow_run_block_id,
},
)}
onClick={(event) => {
event.stopPropagation();
onBlockItemClick(block);
}}
ref={refCallback}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-3">
<div className="rounded bg-slate-elevation5 p-2">
<WorkflowBlockIcon
workflowBlockType={block.block_type}
className="size-6"
/>
</div>
<div className={cn({ "ml-3 border-l border-slate-700 pl-3": depth > 0 })}>
<div
className={cn(
"cursor-pointer space-y-4 rounded border border-slate-600 p-4",
{
"border-slate-50":
isWorkflowRunBlock(activeItem) &&
activeItem.workflow_run_block_id === block.workflow_run_block_id,
},
)}
onClick={(event) => {
event.stopPropagation();
onBlockItemClick(block);
}}
ref={refCallback}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-3">
<div className="rounded bg-slate-elevation5 p-2">
<WorkflowBlockIcon
workflowBlockType={block.block_type}
className="size-6"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm">
{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
<div className="flex flex-col gap-1">
<span className="text-sm">
{workflowBlockTitle[block.block_type]}
</span>
)}
</div>
</div>
<div className="flex gap-2">
{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>
</>
<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>
)}
</div>
{duration && showDuration && (
<div className="pr-[5px] text-xs text-[#00ecff]">
{duration}
</div>
<div className="flex gap-2">
{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>
{block.description ? (
<div className="text-xs text-slate-400">{block.description}</div>
) : null}
{block.block_type === "conditional" && block.executed_branch_id && (
<div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
{hasEvaluations(block.output) && block.output.evaluations ? (
// New format: show all branch evaluations
<div className="space-y-2">
{block.output.evaluations.map((evaluation, index) => (
<div
key={evaluation.branch_id || index}
className={cn(
"rounded border px-2 py-1.5",
evaluation.is_matched
? "border-success/50 bg-success/10"
: "border-slate-600 bg-slate-elevation3",
)}
>
{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>
)}
{block.block_type === "conditional" && block.executed_branch_id && (
<div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
{hasEvaluations(block.output) && block.output.evaluations ? (
// New format: show all branch evaluations
<div className="space-y-2">
{block.output.evaluations.map((evaluation, index) => (
<div
key={evaluation.branch_id || index}
className={cn(
"rounded border px-2 py-1.5",
evaluation.is_matched
? "border-success/50 bg-success/10"
: "border-slate-600 bg-slate-elevation3",
)}
>
{evaluation.is_default ? (
<div className="text-slate-300">
<span className="font-medium">Default branch</span>
{evaluation.is_matched && (
<span className="text-success"> Matched</span>
<span className="ml-2 text-success"> Matched</span>
)}
</div>
</div>
)}
{evaluation.is_matched && evaluation.next_block_label && (
<div className="mt-1 text-slate-400">
Executing next block:{" "}
<span className="font-medium text-slate-300">
{evaluation.next_block_label}
</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">
-&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>
) : (
// 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">
Executing next block:{" "}
<span className="font-medium text-slate-300">
{block.executed_branch_next_block}
</span>
</div>
)}
</>
)}
<CollapsibleContent className="px-2 pb-2 pt-2">
<TimelineSubItems
items={group.items}
activeItem={activeItem}
depth={depth + 1}
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</div>
{block.block_type === "human_interaction" && (
<WorkflowRunHumanInteraction workflowRunBlock={block} />
)}
{hasNestedChildren && isConditionalBlock && (
<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) => {
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 && !isConditionalBlock && (
<TimelineSubItems
items={subItems}
activeItem={activeItem}
depth={depth + 1}
onActionClick={onActionClick}
onBlockItemClick={onBlockItemClick}
onThoughtCardClick={onThoughtCardClick}
finallyBlockLabel={finallyBlockLabel}
/>
);
})}
{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>
);
}