From 6a2705d82e7791892b0ba1eafc7ee50a9624f4a5 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Thu, 28 May 2026 06:46:19 -0700 Subject: [PATCH] SKY-6729: Add llm_cost, proxy_cost, captcha_cost columns to task_runs (#6219) Co-authored-by: Claude Opus 4.7 (1M context) --- ...6e511753d_add_cost_columns_to_task_runs.py | 32 + .../building-workflows/build-a-workflow.mdx | 2 +- .../browserProfiles/BrowserProfilesList.tsx | 33 +- .../tasks/detail/ActionCardCompact.test.tsx | 16 +- .../routes/tasks/detail/ActionCardCompact.tsx | 122 +- .../src/routes/workflows/WorkflowRun.tsx | 132 +- .../debugger/DebuggerRunTimeline.tsx | 3 +- .../workflows/types/workflowRunTypes.ts | 15 +- .../workflowRun/ThoughtCard.test.tsx | 77 + .../workflows/workflowRun/ThoughtCard.tsx | 58 +- .../WorkflowRunBlockDetail.test.tsx | 325 ++++ .../workflowRun/WorkflowRunBlockDetail.tsx | 266 ++++ .../workflowRun/WorkflowRunOverview.tsx | 4 + .../workflowRun/WorkflowRunTimeline.tsx | 176 +- .../WorkflowRunTimelineBlockItem.test.tsx | 406 ++++- .../WorkflowRunTimelineBlockItem.tsx | 1411 +++++++++-------- .../BlockDetailConditional.test.tsx | 167 ++ .../blockDetail/BlockDetailConditional.tsx | 157 ++ .../blockDetail/BlockDetailGeneric.tsx | 26 + .../blockDetail/BlockDetailHeader.test.tsx | 146 ++ .../blockDetail/BlockDetailHttpRequest.tsx | 27 + .../BlockDetailHumanInteraction.tsx | 42 + .../blockDetail/BlockDetailLoop.test.tsx | 125 ++ .../blockDetail/BlockDetailLoop.tsx | 82 + .../blockDetail/BlockDetailTask.test.tsx | 154 ++ .../blockDetail/BlockDetailTask.tsx | 48 + .../blockDetail/BlockDetailThought.tsx | 35 + .../BlockDetailWorkflowTrigger.tsx | 72 + .../blockDetail/BlockInspector.test.tsx | 59 + .../blockDetail/BlockInspector.tsx | 435 +++++ .../workflowRun/blockDetail/EmptyState.tsx | 14 + .../workflowRun/blockDetail/formatValue.ts | 18 + .../workflowRun/blockDetail/shared.tsx | 404 +++++ .../workflowRun/workflowTimelineUtils.test.ts | 513 ++++++ .../workflowRun/workflowTimelineUtils.ts | 293 +++- skyvern/forge/agent_functions.py | 8 + skyvern/forge/sdk/db/models.py | 3 + skyvern/forge/sdk/db/repositories/tasks.py | 12 +- skyvern/forge/sdk/schemas/runs.py | 3 + skyvern/services/task_v2_service.py | 2 +- .../test_workflow_run_timeline_tree.py | 25 + 41 files changed, 5020 insertions(+), 928 deletions(-) create mode 100644 alembic/versions/2026_05_28_0106-c636e511753d_add_cost_columns_to_task_runs.py create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailGeneric.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHeader.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHttpRequest.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHumanInteraction.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailThought.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailWorkflowTrigger.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/EmptyState.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/formatValue.ts create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/shared.tsx create mode 100644 skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.test.ts diff --git a/alembic/versions/2026_05_28_0106-c636e511753d_add_cost_columns_to_task_runs.py b/alembic/versions/2026_05_28_0106-c636e511753d_add_cost_columns_to_task_runs.py new file mode 100644 index 000000000..56ff97bcf --- /dev/null +++ b/alembic/versions/2026_05_28_0106-c636e511753d_add_cost_columns_to_task_runs.py @@ -0,0 +1,32 @@ +"""add_cost_columns_to_task_runs + +Revision ID: c636e511753d +Revises: 0d28973280f5 +Create Date: 2026-05-28T01:06:49.862871+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c636e511753d" +down_revision: Union[str, None] = "0d28973280f5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.add_column("task_runs", sa.Column("llm_cost", sa.Numeric(), nullable=True)) + op.add_column("task_runs", sa.Column("proxy_cost", sa.Numeric(), nullable=True)) + op.add_column("task_runs", sa.Column("captcha_cost", sa.Numeric(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("task_runs", "captcha_cost") + op.drop_column("task_runs", "proxy_cost") + op.drop_column("task_runs", "llm_cost") diff --git a/docs/cloud/building-workflows/build-a-workflow.mdx b/docs/cloud/building-workflows/build-a-workflow.mdx index f9b5b12c5..e52633ce9 100644 --- a/docs/cloud/building-workflows/build-a-workflow.mdx +++ b/docs/cloud/building-workflows/build-a-workflow.mdx @@ -149,7 +149,7 @@ This uses 5 blocks across 3 categories: browser automation, data and extraction, | Field | Value | |-------|-------| | **Label** | `process_each` | - | **Loop Value** | `{{ scrape_listings_output.listings }}` | + | **Loop Value** | `{{ scrape_listings_output.extracted_information.listings }}` | This iterates over the 5 listings extracted in the previous step. Now add a block *inside* the loop. Hover over the **+** inside the loop body, select **Add Block**, and choose **Text Prompt Block**. diff --git a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx index 2e92ad239..0a721d4c7 100644 --- a/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx +++ b/skyvern-frontend/src/routes/browserProfiles/BrowserProfilesList.tsx @@ -15,11 +15,13 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, + TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useBrowserProfilesQuery } from "@/routes/workflows/hooks/useBrowserProfilesQuery"; +import { useBrowserProfileCreateStore } from "@/store/useBrowserProfileCreateStore"; import { cn } from "@/util/utils"; import { BrowserProfileItem } from "./BrowserProfileItem"; @@ -73,6 +75,10 @@ function BrowserProfilesList({ searchKey }: Props = {}) { const isNextDisabled = isFetching || !nextPageProfiles || nextPageProfiles.length === 0; + const activeCreate = useBrowserProfileCreateStore((state) => state.active); + const hasSearch = Boolean(searchKey && searchKey.length > 0); + const showPlaceholderRow = Boolean(activeCreate && page === 1 && !hasSearch); + if (isLoading) { return (
@@ -100,9 +106,8 @@ function BrowserProfilesList({ searchKey }: Props = {}) { } const pageItems = profiles ?? []; - const hasSearch = Boolean(searchKey && searchKey.length > 0); - if (pageItems.length === 0 && page === 1) { + if (pageItems.length === 0 && page === 1 && !showPlaceholderRow) { return (
{hasSearch ? ( @@ -149,6 +154,30 @@ function BrowserProfilesList({ searchKey }: Props = {}) { + {showPlaceholderRow && activeCreate ? ( + + +
+ + + {activeCreate.name} + +
+
+ + + + + + + + + + + + +
+ ) : null} {pageItems.map((profile) => ( { const onToggleExpanded = vi.fn(); render( { expect(onToggleExpanded).not.toHaveBeenCalled(); }); - it("renders reasoning, input, and confidence in the expanded panel", () => { + it("renders reasoning inline, input in the expanded panel, and confidence as a chip", () => { render( { />, ); - expect(screen.getByText("Reasoning")).toBeDefined(); - // reasoning appears in both the inline preview and the expanded panel - expect(screen.getAllByText("Type the email address").length).toBe(2); + // Reasoning is always inline now (not duplicated under a "Reasoning" heading) + expect(screen.getAllByText("Type the email address").length).toBe(1); + // Input value lives inside the expanded panel expect(screen.getByText("Input")).toBeDefined(); expect(screen.getByText("user@example.com")).toBeDefined(); - expect(screen.getByText("Confidence")).toBeDefined(); + // Confidence renders as an inline chip in the top strip — no heading expect(screen.getByText("92%")).toBeDefined(); }); diff --git a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx b/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx index 053d000f3..4793f220e 100644 --- a/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/ActionCardCompact.tsx @@ -79,6 +79,10 @@ function ActionCardCompact({ ? (action.text ?? action.response) : null; + // Only the input value is worth hiding behind a chevron — it can be long + // or sensitive. Confidence is short metadata so we render it inline. + const hasExpandableDetail = inputValue != null && inputValue.length > 0; + return (
-
- - {fromScript && ( - - - - - - - - - Code Execution - - - +
)} + + {hasExpandableDetail && ( + // Sibling button (not nested) so the outer select button doesn't + // contain interactive content. Absolute-positioned to keep the + // visual chevron in the top-right corner of the card. -
- - {action.reasoning && ( -
-
- Reasoning -
-
- {action.reasoning} -
-
- )} + )} + {inputValue != null && inputValue.length > 0 && ( -
-
+
+
Input
-
+
{inputValue}
)} - {confidencePct != null && ( -
- - Confidence - - - {confidencePct}% - -
- )}
diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 80f621e53..60a219222 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -1,5 +1,5 @@ import { AxiosError } from "axios"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "@/api/AxiosClient"; import { ProxyLocation, Status } from "@/api/types"; import { FailureCategoryBadge } from "@/components/FailureCategoryBadge"; @@ -37,9 +37,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link, Outlet, useSearchParams } from "react-router-dom"; import { statusIsCancellable, statusIsFinalized } from "../tasks/types"; import { useWorkflowRunWithWorkflowQuery } from "./hooks/useWorkflowRunWithWorkflowQuery"; +import { WorkflowRunBlockDetail } from "./workflowRun/WorkflowRunBlockDetail"; import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline"; import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery"; -import { findActiveItem } from "./workflowRun/workflowTimelineUtils"; +import { + findActiveItem, + parseActiveIterationParam, +} from "./workflowRun/workflowTimelineUtils"; import { pickDownloadedFileFilename } from "./workflowRun/blockDownloadedFiles"; import { isBlockItem } from "./types/workflowRunTypes"; import { Label } from "@/components/ui/label"; @@ -58,11 +62,74 @@ import { WorkflowRunVerificationCodeForm } from "@/routes/workflows/workflowRun/ import { ScriptUpdateCard } from "@/routes/workflows/workflowRun/ScriptUpdateCard"; import { useFallbackEpisodesQuery } from "@/routes/workflows/hooks/useFallbackEpisodesQuery"; +function WorkflowRunRightColumn({ + activeItem, + activeIteration, + timeline, + timelineReady, + onSetActiveItem, + onSetActiveIteration, +}: { + activeItem: ReturnType; + activeIteration: number | null; + timeline: NonNullable["data"]>; + timelineReady: boolean; + onSetActiveItem: (id: string) => void; + onSetActiveIteration: (loopBlockId: string, iterationIndex: number) => void; +}) { + return ( +
+
+ { + onSetActiveItem(item.action.action_id); + }} + onBlockItemSelected={(item) => { + onSetActiveItem(item.workflow_run_block_id); + }} + onThoughtItemSelected={(item) => { + onSetActiveItem(item.thought_id); + }} + onLiveStreamSelected={() => { + onSetActiveItem("stream"); + }} + onIterationSelected={(loopBlock, iterationIndex) => { + onSetActiveIteration( + loopBlock.workflow_run_block_id, + iterationIndex, + ); + }} + /> +
+
+ { + onSetActiveItem(item.action.action_id); + }} + onThoughtSelect={(thought) => { + onSetActiveItem(thought.thought_id); + }} + /> +
+
+ ); +} + function WorkflowRun() { const [searchParams, setSearchParams] = useSearchParams(); + const searchParamsRef = useRef(searchParams); + searchParamsRef.current = searchParams; const embed = searchParams.get("embed"); const isEmbedded = embed === "true"; const active = searchParams.get("active"); + const iterationParam = searchParams.get("iteration"); + const activeIteration = parseActiveIterationParam(iterationParam); const workflowRunId = useFirstParam("workflowRunId", "runId"); const workflowPermanentIdParam = useFirstParam("workflowPermanentId"); const credentialGetter = useCredentialGetter(); @@ -259,10 +326,35 @@ function WorkflowRun() {
) : null; + const updateSearchParams = useCallback( + (mutate: (params: URLSearchParams) => void) => { + setSearchParams( + () => { + const next = new URLSearchParams(searchParamsRef.current); + mutate(next); + searchParamsRef.current = next; + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + function handleSetActiveItem(id: string) { - searchParams.set("active", id); - setSearchParams(searchParams, { - replace: true, + updateSearchParams((next) => { + next.set("active", id); + next.delete("iteration"); + }); + } + + function handleSetActiveIteration( + loopBlockId: string, + iterationIndex: number, + ) { + updateSearchParams((next) => { + next.set("active", loopBlockId); + next.set("iteration", String(iterationIndex)); }); } @@ -337,7 +429,7 @@ function WorkflowRun() { to: "output", }, { - label: "Parameters", + label: "Inputs", to: "parameters", }, { @@ -604,27 +696,19 @@ function WorkflowRun() { )}
)} -
+ {/* 18rem accounts for nav, run metadata, tabs, and page gutters above this work area. */} +
-
- { - handleSetActiveItem(item.action.action_id); - }} - onBlockItemSelected={(item) => { - handleSetActiveItem(item.workflow_run_block_id); - }} - onLiveStreamSelected={() => { - handleSetActiveItem("stream"); - }} - onObserverThoughtCardSelected={(item) => { - handleSetActiveItem(item.thought_id); - }} - /> -
+
); diff --git a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx index 2310918ac..2815fa896 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRunTimeline.tsx @@ -103,7 +103,8 @@ function DebuggerRunTimeline({ block={timelineItem.block} onActionClick={onActionItemSelected} onBlockItemClick={onBlockItemSelected} - onThoughtCardClick={onObserverThoughtCardSelected} + onThoughtClick={onObserverThoughtCardSelected} + renderThoughts />
); diff --git a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts index 1fb533ae0..1beb28c7f 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts @@ -1,5 +1,5 @@ import { ActionsApiResponse, RunEngine, Status } from "@/api/types"; -import { isTaskVariantBlock, WorkflowBlockType } from "./workflowTypes"; +import { WorkflowBlockType } from "./workflowTypes"; import { ActionItem } from "../workflowRun/WorkflowRunOverview"; export const WorkflowRunTimelineItemTypes = { @@ -49,6 +49,9 @@ export type WorkflowRunBlock = { body?: string | null; prompt?: string | null; wait_sec?: number | null; + method?: string | null; + headers?: Record | null; + request_body?: Record | Array | string | null; executed_branch_id?: string | null; executed_branch_expression?: string | null; executed_branch_result?: boolean | null; @@ -124,16 +127,16 @@ export function isBlockItem( ); } -function isTaskVariantBlockItem(item: unknown) { - return isBlockItem(item) && isTaskVariantBlock(item.block); -} - export function countActionsInTimeline( timelineItems: Array, ): number { return timelineItems.reduce((total, item) => { let count = 0; - if (isTaskVariantBlockItem(item)) { + if (isBlockItem(item)) { + // Workflow-run blocks can carry backend-generated actions outside the + // editor's task-variant taxonomy, e.g. conditional extraction actions. + // Count the API surface directly so the timeline header matches what + // users can expand and inspect in the run detail panel. count += item.block?.actions?.length ?? 0; } if (item.children.length > 0) { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.test.tsx new file mode 100644 index 000000000..580ff9939 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ObserverThought } from "../types/workflowRunTypes"; +import { ThoughtCard } from "./ThoughtCard"; + +function buildThought( + overrides: Partial = {}, +): ObserverThought { + return { + thought_id: "th_default", + user_input: null, + observation: null, + thought: "Considering whether to scroll the page", + answer: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("ThoughtCard", () => { + it("renders the 'Thought' label and the thought body when there is content", () => { + render( + {}} + />, + ); + expect(screen.getByText("Thought")).toBeDefined(); + expect( + screen.getByText("Considering whether to scroll the page"), + ).toBeDefined(); + }); + + it("renders 'Thinking' when there is no answer or thought yet", () => { + render( + {}} + />, + ); + expect(screen.getByText("Thinking")).toBeDefined(); + }); + + it("prefers the answer over the thought when both are present", () => { + render( + {}} + />, + ); + expect(screen.getByText("final answer text")).toBeDefined(); + expect(screen.queryByText("intermediate thought")).toBeNull(); + }); + + it("fires onClick when the card is clicked", () => { + const onClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Thought")); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx index dc6b8fae7..633a58bf8 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx @@ -1,8 +1,7 @@ -import { StatusPill } from "@/components/ui/status-pill"; import { QuestionMarkIcon } from "@radix-ui/react-icons"; -import { ObserverThought } from "../types/workflowRunTypes"; import { BrainIcon } from "@/components/icons/BrainIcon"; -import { RunCard } from "./RunCard"; +import { cn } from "@/util/utils"; +import { ObserverThought } from "../types/workflowRunTypes"; type Props = { active: boolean; @@ -12,30 +11,39 @@ type Props = { }; function ThoughtCard({ thought, onClick, active, cardClassName }: Props) { + const body = thought.answer || thought.thought; + const titleText = body ? "Thought" : "Thinking"; + return ( - onClick(thought)} - className={ - cardClassName ? `space-y-3 p-4 ${cardClassName}` : "space-y-3 p-4" - } - > -
-
- - {(thought.answer || thought.thought) && Thought} - {!thought.answer && !thought.thought && Thinking} -
- }> - Decision - -
- {(thought.answer || thought.thought) && ( -
- {thought.answer || thought.thought} -
+
+ > + +
); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.test.tsx new file mode 100644 index 000000000..31ceead26 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.test.tsx @@ -0,0 +1,325 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); +vi.mock("../hooks/useWorkflowRunWithWorkflowQuery", () => ({ + useWorkflowRunWithWorkflowQuery: () => ({ data: null }), +})); +vi.mock("../hooks/useWorkflowRunTimelineQuery", () => ({ + useWorkflowRunTimelineQuery: () => ({ data: [], isLoading: false }), +})); +vi.mock("./WorkflowRunHumanInteraction", () => ({ + WorkflowRunHumanInteraction: () => null, +})); + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ActionTypes, Status, type ActionsApiResponse } from "@/api/types"; +import type { + WorkflowRunBlock, + WorkflowRunTimelineBlockItem, +} from "../types/workflowRunTypes"; +import { WorkflowRunBlockDetail } from "./WorkflowRunBlockDetail"; + +function buildBlock( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_default", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "http_request", + label: null, + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: null, + current_value: null, + current_index: null, + ...overrides, + }; +} + +function buildBlockItem(block: WorkflowRunBlock): WorkflowRunTimelineBlockItem { + return { + type: "block", + block, + children: [], + thought: null, + created_at: block.created_at, + modified_at: block.modified_at, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("WorkflowRunBlockDetail router", () => { + it("renders the extraction detail header for an extraction block but no longer shows the goal/criteria fields", () => { + const block = buildBlock({ + block_type: "extraction", + data_extraction_goal: "Pull the price from the page", + }); + render(); + // Header still labels the block type + expect(screen.getByText("Extraction")).toBeDefined(); + // Goal field is intentionally hidden in the detail body (feedback #4/#6/#9) + expect(screen.queryByText(/extraction goal/i)).toBeNull(); + expect(screen.queryByText("Pull the price from the page")).toBeNull(); + }); + + it("renders the failure_reason in the detail body (not the header) when set", () => { + const block = buildBlock({ + block_type: "extraction", + failure_reason: "Could not locate the price element", + }); + render(); + expect(screen.getByText("Failure")).toBeDefined(); + expect( + screen.getAllByText("Could not locate the price element").length, + ).toBeGreaterThanOrEqual(1); + }); + + it("renders block inputs and searchable outputs inside the detail panel inspector", () => { + const block = buildBlock({ + block_type: "http_request", + label: "fetch_report", + url: "https://example.test/report", + output: { + status_code: 200, + response_json: { + result: "low_value", + nested: { keep: "visible" }, + }, + }, + }); + + render(); + + expect(screen.getByText("output")).toBeDefined(); + expect(screen.getAllByText("status_code").length).toBeGreaterThanOrEqual(1); + expect( + screen.getAllByPlaceholderText("Search JSON").length, + ).toBeGreaterThanOrEqual(1); + + fireEvent.change(screen.getAllByPlaceholderText("Search JSON")[0]!, { + target: { value: "low_value" }, + }); + expect( + screen.getAllByText( + (_, element) => element?.textContent === '"low_value"', + ).length, + ).toBeGreaterThanOrEqual(1); + + fireEvent.click(screen.getByRole("tab", { name: "Inputs" })); + expect(screen.getByText("URL")).toBeDefined(); + expect(screen.getByText("https://example.test/report")).toBeDefined(); + }); + + it("renders the conditional detail (branch evaluation) for a conditional block", () => { + const block = buildBlock({ + block_type: "conditional", + executed_branch_id: "b1", + output: { + evaluations: [ + { + branch_id: "b1", + branch_index: 0, + criteria_type: "jinja2_template", + original_expression: "{{ x == 1 }}", + rendered_expression: "1 == 1", + result: true, + is_matched: true, + is_default: false, + next_block_label: "next_block", + error: null, + }, + ], + }, + }); + render(); + // The branch evaluations section renders the expression as a code chunk + expect(screen.getByText("{{ x == 1 }}")).toBeDefined(); + }); + + it("renders the loop detail (iterable values) for a for_loop block with values", () => { + const block = buildBlock({ + block_type: "for_loop", + loop_values: [{ name: "alpha" }, { name: "beta" }], + }); + render(); + // Header section enumerates the iterable count + expect(screen.getByText(/iterable values \(2\)/i)).toBeDefined(); + }); + + it("renders the http_request detail (URL) for an http_request block", () => { + const block = buildBlock({ + block_type: "http_request", + url: "https://example.test/endpoint", + }); + render(); + expect(screen.getByText("https://example.test/endpoint")).toBeDefined(); + }); + + it("renders an empty state when there is no selection and no resolvable target", () => { + render(); + expect(screen.getByText(/no block selected/i)).toBeDefined(); + }); + + it("renders the human_interaction detail (instructions + recipients) for a human_interaction block", () => { + const block = buildBlock({ + block_type: "human_interaction", + instructions: "Please verify the order total before we ship.", + subject: "SKY-10066 approval", + recipients: ["celal@skyvern.com", "ops@skyvern.com"], + }); + render(); + expect( + screen.getByText("Please verify the order total before we ship."), + ).toBeDefined(); + expect(screen.getByText("SKY-10066 approval")).toBeDefined(); + expect(screen.getByText("celal@skyvern.com")).toBeDefined(); + expect(screen.getByText("ops@skyvern.com")).toBeDefined(); + }); + + it("defaults to the running block when no selection but the run has one", () => { + const running = buildBlock({ + workflow_run_block_id: "wrb_running", + block_type: "extraction", + label: "extract_posts", + data_extraction_goal: "Pull the running goal", + status: Status.Running, + }); + render( + , + ); + // Should render the running extraction's detail header + expect(screen.getByText("Extraction")).toBeDefined(); + expect(screen.getByText("extract_posts")).toBeDefined(); + expect(screen.getByText("Running")).toBeDefined(); + }); + + it("surfaces the selected action directly under the block header", () => { + const action: ActionsApiResponse = { + action_id: "act_extract", + action_type: ActionTypes.extract, + status: Status.Completed, + task_id: null, + step_id: null, + step_order: null, + action_order: null, + reasoning: "Extract the event date from the page", + description: null, + intention: null, + response: null, + text: null, + created_by: null, + confidence_float: 1, + }; + const block = buildBlock({ + workflow_run_block_id: "wrb_task", + block_type: "task_v2", + label: "calendar_lookup", + actions: [action], + }); + + render( + , + ); + + expect(screen.getByText("calendar_lookup")).toBeDefined(); + expect(screen.getByText("Selected action")).toBeDefined(); + expect( + screen.getAllByText("Extract the event date from the page")[0], + ).toBeDefined(); + }); + + it("ignores a stale activeIteration when falling back via 'stream'", () => { + // A prior selection set ?iteration=2; the user then jumped to Live so + // activeItem became 'stream' and the resolved block is a loop unrelated + // to that prior selection. The header must not show "Iteration 3". + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop_running", + block_type: "for_loop", + label: "iterate_items", + loop_values: ["alpha", "beta", "gamma"], + current_index: 0, + current_value: "alpha", + status: Status.Running, + }); + render( + , + ); + // Loop's own current_index=0 means the chip should say "Iteration 1", + // not "Iteration 3" from the stale URL hint. + expect(screen.queryByText(/Iteration 3/)).toBeNull(); + expect(screen.getByText(/Iteration 1/)).toBeDefined(); + }); +}); + +describe("WorkflowRunBlockDetail cold-start skeleton", () => { + it("renders the header skeleton and an empty body while the timeline query is loading", async () => { + vi.resetModules(); + vi.doMock("../hooks/useWorkflowRunWithWorkflowQuery", () => ({ + useWorkflowRunWithWorkflowQuery: () => ({ data: null }), + })); + vi.doMock("../hooks/useWorkflowRunTimelineQuery", () => ({ + useWorkflowRunTimelineQuery: () => ({ + data: undefined, + isLoading: false, + }), + })); + vi.doMock("./WorkflowRunHumanInteraction", () => ({ + WorkflowRunHumanInteraction: () => null, + })); + const { WorkflowRunBlockDetail: ReloadedBlockDetail } = + await import("./WorkflowRunBlockDetail"); + const { container } = render( + , + ); + // No real block detail strings (block-type label, status, etc.) should + // appear yet — only skeleton elements. + expect(screen.queryByText(/no block selected/i)).toBeNull(); + expect(screen.queryByText(/iteration/i)).toBeNull(); + // The skeleton uses the shared Skeleton component which carries a + // `bg-slate` style; pick out at least one such placeholder block. + const skeletons = container.querySelectorAll('[class*="animate-pulse"]'); + expect(skeletons.length).toBeGreaterThan(0); + vi.doUnmock("../hooks/useWorkflowRunTimelineQuery"); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.tsx new file mode 100644 index 000000000..c48b2ee26 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlockDetail.tsx @@ -0,0 +1,266 @@ +import type { ActionsApiResponse } from "@/api/types"; +import { useState } from "react"; +import { ActionCardCompact } from "@/routes/tasks/detail/ActionCardCompact"; +import { + isAction, + isObserverThought, + isWorkflowRunBlock, + type ObserverThought, + type WorkflowRunBlock, + type WorkflowRunTimelineItem, +} from "../types/workflowRunTypes"; +import type { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; +import { + findBlockSurroundingAction, + findBlockSurroundingThought, + findLastExecutedBlock, + findRunningBlock, + findThoughtsForBlock, +} from "./workflowTimelineUtils"; +import { BlockDetailConditional } from "./blockDetail/BlockDetailConditional"; +import { BlockDetailGeneric } from "./blockDetail/BlockDetailGeneric"; +import { BlockDetailHttpRequest } from "./blockDetail/BlockDetailHttpRequest"; +import { BlockDetailHumanInteraction } from "./blockDetail/BlockDetailHumanInteraction"; +import { BlockDetailLoop } from "./blockDetail/BlockDetailLoop"; +import { BlockDetailTask } from "./blockDetail/BlockDetailTask"; +import { BlockDetailThought } from "./blockDetail/BlockDetailThought"; +import { BlockDetailWorkflowTrigger } from "./blockDetail/BlockDetailWorkflowTrigger"; +import { BlockInspector } from "./blockDetail/BlockInspector"; +import { EmptyState } from "./blockDetail/EmptyState"; +import { + BlockDetailHeader, + BlockDetailHeaderSkeleton, +} from "./blockDetail/shared"; + +type Props = { + activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; + timeline: Array; + timelineReady?: boolean; + onActionSelect?: (payload: { + block: WorkflowRunBlock; + action: ActionsApiResponse; + }) => void; + onThoughtSelect?: (thought: ObserverThought) => void; +}; + +function isLoopBlock(block: WorkflowRunBlock): boolean { + return block.block_type === "for_loop" || block.block_type === "while_loop"; +} + +function SelectedActionHeader({ + action, + block, + index, + onActionSelect, +}: { + action: ActionsApiResponse; + block: WorkflowRunBlock; + index: number; + onActionSelect?: Props["onActionSelect"]; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+
+ Selected action +
+ setExpanded((prev) => !prev)} + onSelect={() => onActionSelect?.({ block, action })} + cardClassName="bg-slate-800/70" + /> +
+ ); +} + +function getActionDisplayIndex( + block: WorkflowRunBlock, + action: ActionsApiResponse, +): number { + const actionsTopDown = [...(block.actions ?? [])].reverse(); + const index = actionsTopDown.findIndex( + (item) => item.action_id === action.action_id, + ); + return index === -1 ? 1 : index + 1; +} + +function renderBodyForBlock( + block: WorkflowRunBlock, + activeItem: WorkflowRunOverviewActiveElement, + onActionSelect: Props["onActionSelect"], + onThoughtSelect: Props["onThoughtSelect"], + activeIteration: number | null, + timeline: Array, +) { + const thoughts = findThoughtsForBlock(timeline, block); + switch (block.block_type) { + case "task": + case "task_v2": + case "action": + case "navigation": + case "login": + case "validation": + case "extraction": + return ( + + ); + case "conditional": + return ; + case "for_loop": + case "while_loop": + return ; + case "http_request": + return ; + case "workflow_trigger": + return ; + case "human_interaction": + return ; + default: + return ; + } +} + +function WorkflowRunBlockDetail({ + activeItem, + activeIteration = null, + timeline, + timelineReady = true, + onActionSelect, + onThoughtSelect, +}: Props) { + // activeIteration is a URL hint scoped to a specific selection. In + // fallback mode (null or "stream") the resolved block may not be the + // loop the iteration was set for — ignore it to avoid stale labels. + const effectiveIteration = + activeItem === null || activeItem === "stream" ? null : activeIteration; + + // Cold-start: timeline data hasn't arrived yet. Check data === undefined + // rather than isLoading because the timeline query is gated on the + // workflowPermanentId (resolved by useWorkflowRunWithWorkflowQuery), so + // during the workflow-run fetch the timeline query is `enabled: false` + // and isLoading reports false even though there's no data to render. + if (!timelineReady) { + return ( + <> +
+ +
+
+
+
+ + ); + } + + // Resolve which block (if any) the active selection actually points at and + // produce the matching body. Thoughts and the empty state are special: + // they bypass the block header and render only as the body slot. + let resolvedBlock: WorkflowRunBlock | null = null; + let selectedAction: ActionsApiResponse | null = null; + let selectedActionIndex = 1; + let body: React.ReactNode; + + if (activeItem === null || activeItem === "stream") { + // "stream" is a live/default selection mode, not a concrete item ID. + // Resolve it inside the detail panel so polling can move the panel from + // the currently running block to the final leaf without rewriting the URL. + const target = + findRunningBlock(timeline) ?? findLastExecutedBlock(timeline); + if (target) { + resolvedBlock = target; + body = renderBodyForBlock( + target, + activeItem, + onActionSelect, + onThoughtSelect, + effectiveIteration, + timeline, + ); + } else { + body = ; + } + } else if (isAction(activeItem)) { + const parentBlock = findBlockSurroundingAction( + timeline, + activeItem.action_id, + ); + if (parentBlock) { + resolvedBlock = parentBlock; + selectedAction = activeItem; + selectedActionIndex = getActionDisplayIndex(parentBlock, activeItem); + body = renderBodyForBlock( + parentBlock, + activeItem, + onActionSelect, + onThoughtSelect, + effectiveIteration, + timeline, + ); + } else { + body = ; + } + } else if (isObserverThought(activeItem)) { + resolvedBlock = + findBlockSurroundingThought(timeline, activeItem.thought_id) ?? null; + body = ; + } else if (isWorkflowRunBlock(activeItem)) { + resolvedBlock = activeItem; + body = renderBodyForBlock( + activeItem, + activeItem, + onActionSelect, + onThoughtSelect, + effectiveIteration, + timeline, + ); + } else { + body = ; + } + + // The header slot is always present in the DOM; when no block is resolved + // the slot is just an empty zero-height div. + return ( + <> +
+ {resolvedBlock && ( + <> + + + )} +
+
+
+ {resolvedBlock && selectedAction && ( + + )} + {resolvedBlock && } + {body} +
+
+ + ); +} + +export { WorkflowRunBlockDetail }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx index 5aeaef31d..04a62649e 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOverview.tsx @@ -18,6 +18,7 @@ import { WorkflowRunStream } from "./WorkflowRunStream"; import { useSearchParams } from "react-router-dom"; import { findActiveItem, + parseActiveIterationParam, resolveScreenshotBlockId, } from "./workflowTimelineUtils"; import { Skeleton } from "@/components/ui/skeleton"; @@ -40,6 +41,8 @@ export type WorkflowRunOverviewActiveElement = function WorkflowRunOverview() { const [searchParams] = useSearchParams(); const active = searchParams.get("active"); + const iterationParam = searchParams.get("iteration"); + const activeIteration = parseActiveIterationParam(iterationParam); const queryClient = useQueryClient(); const [vncFailed, setVncFailed] = useState(false); const { browserStreamingMode } = useBrowserStreamingMode(); @@ -157,6 +160,7 @@ function WorkflowRunOverview() { workflowRunBlockId={resolveScreenshotBlockId( workflowRunTimeline, selection, + activeIteration, )} /> )} diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx index d5ace5c87..cec714a79 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimeline.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { statusIsFinalized, statusIsNotFinalized } from "@/routes/tasks/types"; import { cn } from "@/util/utils"; import { DotFilledIcon } from "@radix-ui/react-icons"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery"; import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery"; import { @@ -13,34 +13,106 @@ import { isThoughtItem, ObserverThought, WorkflowRunBlock, + WorkflowRunTimelineItem, } from "../types/workflowRunTypes"; -import { ThoughtCard } from "./ThoughtCard"; import { ActionItem, WorkflowRunOverviewActiveElement, } from "./WorkflowRunOverview"; +import { ThoughtCard } from "./ThoughtCard"; import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem"; type Props = { activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; onLiveStreamSelected: () => void; - onObserverThoughtCardSelected: (item: ObserverThought) => void; onActionItemSelected: (item: ActionItem) => void; onBlockItemSelected: (item: WorkflowRunBlock) => void; + onThoughtItemSelected: (item: ObserverThought) => void; + onIterationSelected: ( + loopBlock: WorkflowRunBlock, + iterationIndex: number, + ) => void; }; +function buildBlockOrderIndex( + items: Array, +): ReadonlyMap { + const blocks: Array<{ + id: string; + createdAt: number; + sequence: number; + }> = []; + + function walk(timelineItems: Array) { + for (const item of timelineItems) { + if (isBlockItem(item)) { + const createdAt = new Date(item.created_at).getTime(); + blocks.push({ + id: item.block.workflow_run_block_id, + createdAt: Number.isNaN(createdAt) + ? Number.MAX_SAFE_INTEGER + : createdAt, + sequence: blocks.length, + }); + } + if (item.children.length > 0) { + walk(item.children); + } + } + } + + walk(items); + blocks.sort( + (left, right) => + left.createdAt - right.createdAt || left.sequence - right.sequence, + ); + + return new Map(blocks.map((block, index) => [block.id, index + 1])); +} + +function toTimelineTime(value: string): number { + const time = new Date(value).getTime(); + return Number.isNaN(time) ? Number.MAX_SAFE_INTEGER : time; +} + +function sortTimelineTopDown( + items: Array, +): Array { + if (items.length === 0) return items; + return items + .map((item) => ({ + ...item, + children: sortTimelineTopDown(item.children), + })) + .sort( + (left, right) => + toTimelineTime(left.created_at) - toTimelineTime(right.created_at), + ); +} + function WorkflowRunTimeline({ activeItem, + activeIteration = null, onLiveStreamSelected, - onObserverThoughtCardSelected, onActionItemSelected, onBlockItemSelected, + onThoughtItemSelected, + onIterationSelected, }: Props) { const { data: workflowRun, isLoading: workflowRunIsLoading } = useWorkflowRunWithWorkflowQuery(); const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } = useWorkflowRunTimelineQuery(); + const displayTimeline = useMemo( + () => sortTimelineTopDown(workflowRunTimeline ?? []), + [workflowRunTimeline], + ); + const blockOrder = useMemo( + () => buildBlockOrderIndex(workflowRunTimeline ?? []), + [workflowRunTimeline], + ); // Track known item IDs so we can animate only newly-arrived items const knownItemIdsRef = useRef>(new Set()); @@ -52,7 +124,7 @@ function WorkflowRunTimeline({ useEffect(() => { if (!workflowRunTimeline) return; const ids = new Set(); - for (const item of workflowRunTimeline) { + for (const item of displayTimeline) { if (isBlockItem(item)) { ids.add(item.block.workflow_run_block_id); } else if (isThoughtItem(item)) { @@ -61,7 +133,7 @@ function WorkflowRunTimeline({ } knownItemIdsRef.current = ids; isInitialRenderRef.current = false; - }, [workflowRunTimeline]); + }, [displayTimeline, workflowRunTimeline]); if (workflowRunIsLoading || workflowRunTimelineIsLoading) { return ; @@ -81,53 +153,58 @@ function WorkflowRunTimeline({ const numberOfActions = countActionsInTimeline(workflowRunTimeline); return ( -
-
-
- Actions: {numberOfActions} -
-
- Steps: {workflowRun.total_steps ?? 0} -
-
+
+ Timeline + {numberOfActions > 0 && ( + + · {numberOfActions} {numberOfActions === 1 ? "action" : "actions"} + + )} + + · {workflowRun.total_steps ?? 0}{" "} + {(workflowRun.total_steps ?? 0) === 1 ? "step" : "steps"} + + - Credits:{" "} + ·{" "} {( (workflowRun.credits_used ?? 0) + (workflowRun.cached_credits_used ?? 0) - ).toLocaleString()} -
-
- - -
- {workflowRunIsNotFinalized && ( -
- - Live -
+ ).toLocaleString()}{" "} + credits + + {workflowRunIsNotFinalized && ( + + )} +
+ + +
{workflowRunIsNotFinalized && workflowRunTimeline.length === 0 && ( -
+
Formulating actions...
)} {workflowRunIsFinalized && workflowRunTimeline.length === 0 && ( -
Agent timeline is empty
+
Workflow timeline is empty
)} - {workflowRunTimeline?.map((timelineItem) => { + {displayTimeline.map((timelineItem) => { const itemId = isBlockItem(timelineItem) ? timelineItem.block.workflow_run_block_id : isThoughtItem(timelineItem) @@ -150,10 +227,13 @@ function WorkflowRunTimeline({ @@ -164,10 +244,11 @@ function WorkflowRunTimeline({ return (
); } + return null; })}
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.test.tsx index 77ae288cb..9c8338434 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.test.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.test.tsx @@ -1,54 +1,30 @@ // @vitest-environment jsdom -// AxiosClient reads env vars at module load time — stub it before the -// component tree (which transitively imports it) is resolved. vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); vi.mock("@/hooks/useCredentialGetter", () => ({ useCredentialGetter: () => null, })); -vi.mock("@/hooks/useWorkflowRunViewingV2", () => ({ - useWorkflowRunViewingV2: vi.fn(), -})); import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { type ActionsApiResponse, Status } from "@/api/types"; -import { useWorkflowRunViewingV2 } from "@/hooks/useWorkflowRunViewingV2"; -import type { WorkflowRunBlock } from "../types/workflowRunTypes"; +import { ActionTypes, Status } from "@/api/types"; +import type { + WorkflowRunBlock, + WorkflowRunTimelineBlockItem as TimelineBlockItem, + WorkflowRunTimelineItem, +} from "../types/workflowRunTypes"; import { WorkflowRunTimelineBlockItem } from "./WorkflowRunTimelineBlockItem"; -function buildAction( - overrides: Partial = {}, -): ActionsApiResponse { - return { - action_id: "act_1", - action_type: "click", - status: Status.Completed, - task_id: "task_1", - step_id: "step_1", - step_order: 0, - action_order: 0, - confidence_float: 0.9, - description: null, - reasoning: "Click the submit button", - intention: null, - response: null, - created_by: null, - text: null, - ...overrides, - }; -} - function buildBlock( overrides: Partial = {}, ): WorkflowRunBlock { return { - workflow_run_block_id: "wrb_1", - workflow_run_id: "wr_1", + workflow_run_block_id: "wrb_default", + workflow_run_id: "wr_default", parent_workflow_run_block_id: null, - block_type: "task", - label: "Sample block", + block_type: "http_request", + label: null, description: null, title: null, status: Status.Completed, @@ -65,9 +41,9 @@ function buildBlock( complete_criterion: null, include_action_history_in_verification: null, engine: null, - actions: [buildAction()], - created_at: "2026-05-14T00:00:00Z", - modified_at: "2026-05-14T00:00:00Z", + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", duration: null, loop_values: null, current_value: null, @@ -76,72 +52,360 @@ function buildBlock( }; } +function buildBlockItem( + block: WorkflowRunBlock, + children: Array = [], +): TimelineBlockItem { + return { + type: "block", + block, + children, + thought: null, + created_at: block.created_at, + modified_at: block.modified_at, + }; +} + +const noop = () => {}; + afterEach(() => { cleanup(); - vi.clearAllMocks(); }); -describe("WorkflowRunTimelineBlockItem action renderer", () => { - it("renders the legacy ActionCard when the v2 flag is off", () => { - vi.mocked(useWorkflowRunViewingV2).mockReturnValue(false); +describe("WorkflowRunTimelineBlockItem", () => { + it("highlights the block row when activeItem matches the block id", () => { + const block = buildBlock({ + workflow_run_block_id: "wrb_active", + block_type: "http_request", + label: "fetch_token", + }); + render( + , + ); + // The row's body button reports the active state via aria-pressed + const rowButton = screen.getByRole("button", { name: /fetch_token/i }); + expect(rowButton.getAttribute("aria-pressed")).toBe("true"); + }); + + it("renders a fixed row anatomy with order, type, descriptor, and action count", () => { + const block = buildBlock({ + workflow_run_block_id: "wrb_active", + block_type: "conditional", + label: "block_5", + description: "Planning to branch based on {{bulk_download}} condition.", + actions: [ + { action_id: "act_1" }, + { action_id: "act_2" }, + ] as unknown as WorkflowRunBlock["actions"], + }); render( {}} - onActionClick={() => {}} - onThoughtCardClick={() => {}} + onActionClick={noop} + onBlockItemClick={noop} />, ); - expect(document.querySelector('[data-slot="runcard"]')).not.toBeNull(); + expect(screen.getByText("#5")).toBeDefined(); + expect(screen.getByText("Condition")).toBeDefined(); + expect(screen.getByText("block_5")).toBeDefined(); expect( - document.querySelector('[data-slot="action-card-compact"]'), - ).toBeNull(); + screen.getByText( + /Planning to branch based on {{bulk_download}} condition\./, + ), + ).toBeDefined(); + expect(screen.getByText("2 actions")).toBeDefined(); }); - it("renders ActionCardCompact when the v2 flag is on", () => { - vi.mocked(useWorkflowRunViewingV2).mockReturnValue(true); + it("omits the action count badge when a block has no actions", () => { + const block = buildBlock({ + workflow_run_block_id: "wrb_no_actions", + block_type: "http_request", + label: "fetch_token", + actions: [], + }); render( {}} - onActionClick={() => {}} - onThoughtCardClick={() => {}} + onActionClick={noop} + onBlockItemClick={noop} />, ); - expect( - document.querySelector('[data-slot="action-card-compact"]'), - ).not.toBeNull(); - expect(document.querySelector('[data-slot="runcard"]')).toBeNull(); - expect(screen.getByText("Click the submit button")).toBeDefined(); + expect(screen.queryByText("0 actions")).toBeNull(); }); - it("clicking a compact action selects the action without selecting the block", () => { - vi.mocked(useWorkflowRunViewingV2).mockReturnValue(true); + it("renders action rows under a leaf block and lets the user select an action", () => { const onActionClick = vi.fn(); - const onBlockItemClick = vi.fn(); + const block = buildBlock({ + workflow_run_block_id: "wrb_action_block", + block_type: "task_v2", + label: "Open account page", + actions: [ + { + action_id: "act_second", + action_type: ActionTypes.Click, + status: Status.Completed, + reasoning: "Click the account menu", + created_by: null, + confidence_float: null, + }, + { + action_id: "act_first", + action_type: ActionTypes.extract, + status: Status.Completed, + reasoning: "Extract the calendar event date from the page", + created_by: null, + confidence_float: 1, + }, + ] as unknown as WorkflowRunBlock["actions"], + }); render( {}} + onBlockItemClick={noop} />, ); - fireEvent.click(screen.getByText("Click the submit button")); + const firstAction = screen.getByText( + /Extract the calendar event date from the page/, + ); + const secondAction = screen.getByText(/Click the account menu/); + expect(screen.getByText("Extract Data")).toBeDefined(); + expect(screen.queryByText("100%")).toBeNull(); + expect( + firstAction.compareDocumentPosition(secondAction) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); - expect(onActionClick).toHaveBeenCalledTimes(1); - expect(onBlockItemClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /#1/i })); + + expect(onActionClick).toHaveBeenCalledWith({ + block, + action: expect.objectContaining({ action_id: "act_first" }), + }); + }); + + it("renders action rows under a container block without requiring expansion", () => { + const block = buildBlock({ + workflow_run_block_id: "wrb_container_with_actions", + block_type: "conditional", + label: "Validate report", + actions: [ + { + action_id: "act_extract", + action_type: ActionTypes.extract, + status: Status.Completed, + reasoning: "Extract the condition result from the page", + created_by: null, + confidence_float: 1, + }, + ] as unknown as WorkflowRunBlock["actions"], + }); + const child = buildBlock({ + workflow_run_block_id: "wrb_child", + block_type: "text_prompt", + label: "Next step", + }); + + render( + , + ); + + expect( + screen.getByText(/Extract the condition result from the page/), + ).toBeDefined(); + }); + + it("renders extract action rows for conditional blocks", () => { + const onActionClick = vi.fn(); + const block = buildBlock({ + workflow_run_block_id: "wrb_condition_action", + block_type: "conditional", + label: "check_signin_ok", + actions: [ + { + action_id: "act_condition_extract", + action_type: ActionTypes.extract, + status: Status.Completed, + reasoning: "Extract whether sign-in succeeded from the page", + created_by: null, + confidence_float: 1, + }, + ] as unknown as WorkflowRunBlock["actions"], + }); + + render( + , + ); + + expect(screen.getByText("1 action")).toBeDefined(); + expect(screen.getByText("Extract Data")).toBeDefined(); + expect( + screen.getByText(/Extract whether sign-in succeeded from the page/), + ).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: /#1/i })); + + expect(onActionClick).toHaveBeenCalledWith({ + block, + action: expect.objectContaining({ action_id: "act_condition_extract" }), + }); + }); + + it("renders loop iterations from first to last", () => { + const iterChildA = buildBlock({ + workflow_run_block_id: "wrb_iter1_leaf", + block_type: "http_request", + current_index: 1, + }); + const iterChildB = buildBlock({ + workflow_run_block_id: "wrb_iter0_leaf", + block_type: "http_request", + current_index: 0, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + label: "iterate_items", + loop_values: ["alpha", "beta"], + }); + + render( + , + ); + + const iterations = screen.getAllByText(/iteration \d/i); + expect(iterations.map((node) => node.textContent)).toEqual([ + "Iteration 1", + "Iteration 2", + ]); + }); + + it("expands a deep-linked loop with a selected iteration on initial mount", () => { + // Two iteration's worth of children — both should be revealed. + const iterChildA = buildBlock({ + workflow_run_block_id: "wrb_iter1_leaf", + block_type: "http_request", + current_index: 1, + }); + const iterChildB = buildBlock({ + workflow_run_block_id: "wrb_iter0_leaf", + block_type: "http_request", + current_index: 0, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + label: "iterate_items", + loop_values: ["alpha", "beta"], + }); + render( + , + ); + // Two iteration rows visible → loop expanded → R10 fix verified + expect(screen.getAllByText(/iteration \d/i).length).toBeGreaterThanOrEqual( + 2, + ); + }); + + it("keeps an iteration collapsed after the user clicks the chevron, even when an active descendant appears", () => { + // Render an expanded iteration (groupIndex 0 default-opens), then user + // collapses via chevron. Re-render the same component with an active + // child — the userToggledRef guard should keep it collapsed. + const iterChild = buildBlock({ + workflow_run_block_id: "wrb_iter0_child", + block_type: "http_request", + current_index: 0, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + label: "iterate_items", + loop_values: ["alpha"], + }); + const subItems = [buildBlockItem(iterChild)]; + const { rerender } = render( + , + ); + // Iteration row's chevron — collapse it + const collapseButton = screen.getByRole("button", { + name: /collapse iteration/i, + }); + fireEvent.click(collapseButton); + + // Re-render with the active item flipped to the child block, which + // would otherwise trigger the auto-expand effect (hasActiveDescendant). + rerender( + , + ); + // Chevron stays in "Expand" state (collapsed) thanks to userToggledRef + expect( + screen.queryByRole("button", { name: /collapse iteration/i }), + ).toBeNull(); + expect( + screen.getByRole("button", { name: /expand iteration/i }), + ).toBeDefined(); }); }); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index ffb26c233..2772f7720 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -3,34 +3,32 @@ import { ChevronDownIcon, ChevronRightIcon, CrossCircledIcon, - CubeIcon, - ExternalLinkIcon, + CursorArrowIcon, + Cross2Icon, + DoubleArrowDownIcon, + DropdownMenuIcon, + FileTextIcon, + HandIcon, + InputIcon, + KeyboardIcon, + MagicWandIcon, ReloadIcon, } from "@radix-ui/react-icons"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; -import { useShimmerText } from "./useShimmerText"; +import { useEffect, useMemo, useRef, useState } from "react"; -import { Status } from "@/api/types"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useWorkflowRunViewingV2 } from "@/hooks/useWorkflowRunViewingV2"; -import { ActionCardCompact } from "@/routes/tasks/detail/ActionCardCompact"; + type ActionsApiResponse, + type ActionType, + ActionTypes, + ReadableActionTypes, + Status, +} from "@/api/types"; +import { Collapsible, CollapsibleContent } 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, @@ -40,23 +38,28 @@ import { 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 { aggregateIterationStatus } from "./workflowTimelineUtils"; type Props = { activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; block: WorkflowRunBlock; subItems: Array; depth?: number; + blockOrder?: ReadonlyMap; onBlockItemClick: (block: WorkflowRunBlock) => void; + onIterationClick?: ( + loopBlock: WorkflowRunBlock, + iterationIndex: number, + ) => void; onActionClick: (action: ActionItem) => void; - onThoughtCardClick: (thought: ObserverThought) => void; + onThoughtClick?: (thought: ObserverThought) => void; + renderThoughts?: boolean; finallyBlockLabel?: string | null; workflowRunIsFinalized?: boolean; }; @@ -67,110 +70,190 @@ type LoopIterationGroup = { items: Array; }; -const blockElevationByDepth = [ - "bg-slate-elevation3", - "bg-slate-elevation4", - "bg-slate-elevation5", -]; +const INDENT_PX = 14; +const RAIL_HIGHLIGHT_OFFSET_PX = INDENT_PX / 2; +const RAIL_CONTENT_PADDING_PX = INDENT_PX - 1; -const cardElevationByDepth = [ - "bg-slate-elevation4", - "bg-slate-elevation5", - "bg-slate-elevation5", -]; +const railHighlightStyle = { + marginLeft: `-${RAIL_HIGHLIGHT_OFFSET_PX}px`, + paddingLeft: `${RAIL_CONTENT_PADDING_PX}px`, +}; -function getBlockElevation(depth: number): string { - return blockElevationByDepth[ - Math.min(depth, blockElevationByDepth.length - 1) - ]!; -} - -function getCardElevation(depth: number): string { - return cardElevationByDepth[ - Math.min(depth, cardElevationByDepth.length - 1) - ]!; -} - -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)}...`; -} - -/** - * Renders a loop value with line-clamp-2. Shows a tooltip with the full - * value only when the CSS clamp actually truncates the visible text. - */ -function LoopValueCode({ - collapsed, - fullValue, -}: { - collapsed: string; - fullValue: string; -}) { - const codeRef = useRef(null); - const [isClamped, setIsClamped] = useState(false); - - useEffect(() => { - const el = codeRef.current; - if (el) { - setIsClamped(el.scrollHeight > el.clientHeight + 1); - } - }, [collapsed]); +const timelineActionIcons: Record = { + [ActionTypes.Click]: ( + + ), + [ActionTypes.Hover]: , + [ActionTypes.InputText]: , + [ActionTypes.DownloadFile]: ( + + ), + [ActionTypes.UploadFile]: ( + + ), + [ActionTypes.SelectOption]: , + [ActionTypes.complete]: , + [ActionTypes.wait]: ( + + ), + [ActionTypes.terminate]: , + [ActionTypes.SolveCaptcha]: , + [ActionTypes.extract]: ( + + ), + [ActionTypes.ReloadPage]: , + [ActionTypes.Scroll]: , + [ActionTypes.KeyPress]: , + [ActionTypes.Move]: , + [ActionTypes.NullAction]: , + [ActionTypes.VerificationCode]: , + [ActionTypes.Drag]: , + [ActionTypes.LeftMouse]: ( + + ), + [ActionTypes.GotoUrl]: ( + + ), + [ActionTypes.ClosePage]: , +}; +function IndentRails({ depth }: { depth: number }) { + // Render guide rails only for nested rows. Top-level rows should start with + // content, not a phantom outer timeline rail. + const rails = depth; return ( - - - - - {collapsed} - - - {isClamped && ( - - {fullValue} - - )} - - + <> + {Array.from({ length: rails }).map((_, i) => ( +
+
+
+ ))} + ); } +function StatusDot({ + status, + isFinalized, +}: { + status: Status | null; + isFinalized: boolean; +}) { + const isCompleted = status === Status.Completed; + const isFailure = + status === Status.Failed || + status === Status.Terminated || + status === Status.TimedOut || + status === Status.Canceled; + const isRunning = status === Status.Running && !isFinalized; + + if (isCompleted) { + return ; + } + if (isFailure) { + return ; + } + if (isRunning) { + return ( + + ); + } + return
; +} + +function normalizeInlineText(value: string | null | undefined): string | null { + const normalized = value?.replace(/\s+/g, " ").trim(); + return normalized ? normalized : null; +} + +function getActionSummary(action: ActionsApiResponse): string | null { + return ( + normalizeInlineText(action.reasoning) ?? + normalizeInlineText(action.text) ?? + normalizeInlineText(action.response) + ); +} + +function countSchemaFields(value: WorkflowRunBlock["data_schema"]): number { + if (!value || typeof value !== "object" || Array.isArray(value)) return 0; + const properties = "properties" in value ? value.properties : null; + if ( + properties && + typeof properties === "object" && + !Array.isArray(properties) + ) { + return Object.keys(properties).length; + } + return Object.keys(value).length; +} + +function getTimelineDescriptor(block: WorkflowRunBlock): string { + const explicit = + normalizeInlineText(block.description) ?? + normalizeInlineText(block.navigation_goal) ?? + normalizeInlineText(block.data_extraction_goal) ?? + normalizeInlineText(block.prompt) ?? + normalizeInlineText(block.instructions) ?? + normalizeInlineText(block.url); + + if (explicit) return explicit; + + if (block.block_type === "extraction") { + const fieldCount = countSchemaFields(block.data_schema); + if (fieldCount > 0) { + return `Extract ${fieldCount} ${fieldCount === 1 ? "field" : "fields"}`; + } + } + + if (block.block_type === "for_loop") { + const valueCount = Array.isArray(block.loop_values) + ? block.loop_values.length + : 0; + return valueCount > 0 + ? `Loop over ${valueCount} ${valueCount === 1 ? "value" : "values"}` + : "Loop over values"; + } + + if (block.block_type === "while_loop") { + return "Repeat while condition passes"; + } + + if (block.block_type === "conditional") { + const expression = normalizeInlineText(block.executed_branch_expression); + return expression ? `Branch on ${expression}` : "Branch on a condition"; + } + + return `${workflowBlockTitle[block.block_type]} block`; +} + +function getTimelineTypeLabel(block: WorkflowRunBlock): string { + switch (block.block_type) { + case "conditional": + return "Condition"; + case "for_loop": + case "while_loop": + return "Loop"; + case "navigation": + case "task": + case "task_v2": + return "Task"; + case "http_request": + return "HTTP"; + default: + return workflowBlockTitle[block.block_type]; + } +} + function getLoopIterationGroups( items: Array, ): Array { const groupsByKey = new Map(); const unknownItems: Array = []; - // First pass: group items with known indexes, collect unknown items separately items.forEach((item) => { const currentIndex = isBlockItem(item) ? item.block.current_index : null; const currentValue = isBlockItem(item) ? item.block.current_value : null; @@ -188,7 +271,6 @@ function getLoopIterationGroups( items: [], }); } - const group = groupsByKey.get(groupKey)!; if (!group.currentValue && currentValue) { group.currentValue = currentValue; @@ -196,9 +278,6 @@ function getLoopIterationGroups( group.items.push(item); }); - // Second pass: merge unknown items into the highest-index group. - // During streaming, these are blocks whose current_index hasn't been - // populated yet — they belong to the currently-executing iteration. if (unknownItems.length > 0) { if (groupsByKey.size > 0) { let maxIndex = -1; @@ -213,8 +292,6 @@ function getLoopIterationGroups( unknownItems.forEach((item) => maxGroup!.items.push(item)); } } else { - // No known groups exist yet — all items are unknown. - // The first iteration must be running. groupsByKey.set("index-0", { index: 0, currentValue: null, @@ -224,24 +301,13 @@ function getLoopIterationGroups( } 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; + if (left.index === null && right.index === null) return 0; + if (left.index === null) return -1; + if (right.index === null) return 1; + return left.index - right.index; }); } -/** - * Check whether any block, action, or thought within `items` (recursively) - * matches the currently-active element. Uses an iterative stack to avoid - * deep nesting — same pattern as `findActiveItem` in workflowTimelineUtils. - */ function timelineItemsContainActiveElement( items: Array, activeItem: WorkflowRunOverviewActiveElement, @@ -259,7 +325,6 @@ function timelineItemsContainActiveElement( ) { return true; } - if ( isBlockItem(item) && isAction(activeItem) && @@ -267,7 +332,6 @@ function timelineItemsContainActiveElement( ) { return true; } - if ( isThoughtItem(item) && isObserverThought(activeItem) && @@ -275,7 +339,6 @@ function timelineItemsContainActiveElement( ) { return true; } - stack.push(...item.children); } return false; @@ -284,10 +347,17 @@ function timelineItemsContainActiveElement( type TimelineSubItemsProps = { items: Array; activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; depth: number; + blockOrder?: ReadonlyMap; onBlockItemClick: (block: WorkflowRunBlock) => void; + onIterationClick?: ( + loopBlock: WorkflowRunBlock, + iterationIndex: number, + ) => void; onActionClick: (action: ActionItem) => void; - onThoughtCardClick: (thought: ObserverThought) => void; + onThoughtClick?: (thought: ObserverThought) => void; + renderThoughts?: boolean; finallyBlockLabel?: string | null; workflowRunIsFinalized?: boolean; }; @@ -295,15 +365,19 @@ type TimelineSubItemsProps = { function TimelineSubItems({ items, activeItem, + activeIteration = null, depth, + blockOrder, onBlockItemClick, + onIterationClick, onActionClick, - onThoughtCardClick, + onThoughtClick, + renderThoughts = false, finallyBlockLabel, workflowRunIsFinalized, }: TimelineSubItemsProps) { return ( -
+
{items.map((item) => { if (isBlockItem(item)) { return ( @@ -311,31 +385,119 @@ function TimelineSubItems({ key={item.block.workflow_run_block_id} subItems={item.children} activeItem={activeItem} + activeIteration={activeIteration} block={item.block} depth={depth} + blockOrder={blockOrder} onActionClick={onActionClick} onBlockItemClick={onBlockItemClick} - onThoughtCardClick={onThoughtCardClick} + onIterationClick={onIterationClick} + onThoughtClick={onThoughtClick} + renderThoughts={renderThoughts} finallyBlockLabel={finallyBlockLabel} workflowRunIsFinalized={workflowRunIsFinalized} /> ); } - - if (isThoughtItem(item)) { + if (renderThoughts && isThoughtItem(item) && onThoughtClick) { return ( - +
+ +
); } + // Thoughts are no longer rendered as cards in the compact timeline + // rail; the block detail panel surfaces them inside the owning + // block's view alongside its actions. + return null; + })} +
+ ); +} + +type TimelineActionRowsProps = { + block: WorkflowRunBlock; + activeItem: WorkflowRunOverviewActiveElement; + depth: number; + onActionClick: (action: ActionItem) => void; + workflowRunIsFinalized?: boolean; +}; + +function TimelineActionRows({ + block, + activeItem, + depth, + onActionClick, + workflowRunIsFinalized, +}: TimelineActionRowsProps) { + const actions = block.actions ?? []; + const actionsTopDown = [...actions].reverse(); + + if (actions.length === 0) return null; + + return ( +
+ {actionsTopDown.map((action, index) => { + const isActive = + isAction(activeItem) && activeItem.action_id === action.action_id; + const displayIndex = index + 1; + const icon = timelineActionIcons[action.action_type]; + const label = ReadableActionTypes[action.action_type]; + const summary = getActionSummary(action); + + return ( +
+ +
+
+ +
+
+ ); })}
); @@ -343,567 +505,478 @@ function TimelineSubItems({ function WorkflowRunTimelineBlockItem({ activeItem, + activeIteration = null, block, + blockOrder, subItems, depth = 0, onBlockItemClick, + onIterationClick, onActionClick, - onThoughtCardClick, + onThoughtClick, + renderThoughts = false, finallyBlockLabel, workflowRunIsFinalized = false, }: Props) { - const isViewingV2 = useWorkflowRunViewingV2(); - const [expandedActionId, setExpandedActionId] = useState(null); - const actions = block.actions ?? []; const isFinallyBlock = finallyBlockLabel && block.label === finallyBlockLabel; - - const showDiagnosticLink = - isTaskVariantBlock(block) && - block.task_id !== null && - block.task_id !== undefined; - - const refCallback = useCallback((element: HTMLDivElement | null) => { - if ( - element && - isWorkflowRunBlock(activeItem) && - activeItem.workflow_run_block_id === block.workflow_run_block_id - ) { - element.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - } - // this should only run once at mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const isRunning = block.status === Status.Running && !workflowRunIsFinalized; - const shimmerTitleRef = useShimmerText(isRunning); - const shimmerLabelRef = useShimmerText(isRunning); - const showStatusIndicator = block.status !== null; - - const showSuccessIndicator = - showStatusIndicator && block.status === Status.Completed; - const showFailureIndicator = - showStatusIndicator && - (block.status === Status.Failed || - block.status === Status.Terminated || - block.status === Status.TimedOut || - block.status === Status.Canceled); - const duration = block.duration !== null ? formatDuration(toDuration(block.duration)) : null; + const blockTypeTitle = workflowBlockTitle[block.block_type]; + const blockTypeLabel = getTimelineTypeLabel(block); + const blockName = block.label ?? block.title ?? blockTypeTitle; + const blockIndex = blockOrder?.get(block.workflow_run_block_id); + const descriptor = getTimelineDescriptor(block); + const actions = block.actions ?? []; + const actionCount = actions.length; - // NOTE(jdo): want to put this back; await for now - const showDuration = false as const; - const hasNestedChildren = subItems.length > 0; + const hasActions = actionCount > 0; const isForLoopBlock = block.block_type === "for_loop"; const isWhileLoopBlock = block.block_type === "while_loop"; const isLoopBlock = isForLoopBlock || isWhileLoopBlock; - const isConditionalBlock = block.block_type === "conditional"; - const [childrenOpen, setChildrenOpen] = useState(true); - const loopIterationGroups = useMemo( - () => getLoopIterationGroups(subItems), - [subItems], + () => (isLoopBlock ? getLoopIterationGroups(subItems) : []), + [isLoopBlock, subItems], ); + const hasRenderableNestedChildren = subItems.some( + (item) => isBlockItem(item) || (renderThoughts && isThoughtItem(item)), + ); + // Only treat as a container when there are actual children to reveal. + // Conditionals and loops can be defined as containers structurally, but if + // the runtime didn't model any child blocks under them (e.g. conditionals + // whose "next" block is a flat sibling), showing a chevron that reveals + // nothing is worse than no chevron at all. + const isContainer = hasRenderableNestedChildren || hasActions; - // Track which loop iteration groups are expanded (controlled state). - // Initialise with the most-recent group (index 0) plus any group that - // contains the currently-active element so it is visible on first render. - const [openLoopGroups, setOpenLoopGroups] = useState>(() => { - const initial = new Set(); - if (loopIterationGroups.length > 0) { - initial.add(0); - } - loopIterationGroups.forEach((group, idx) => { - if (timelineItemsContainActiveElement(group.items, activeItem)) { - initial.add(idx); - } - }); - return initial; - }); + // The loop block itself is only "active" when no specific iteration is + // selected — otherwise the iteration row owns the highlight. + // + // When an action inside this block is the selection, the owning block stays + // highlighted — mirrors the loop-iteration pattern so the user never loses + // the parent context after drilling into Panel B's action cards. + const ownsSelectedAction = + isAction(activeItem) && + (block.actions ?? []).some((a) => a.action_id === activeItem.action_id); + const hasResolvedActiveIteration = + activeIteration !== null && + loopIterationGroups.some((group) => group.index === activeIteration); + const isActiveBlock = + ((isWorkflowRunBlock(activeItem) && + activeItem.workflow_run_block_id === block.workflow_run_block_id) || + ownsSelectedAction) && + !(isLoopBlock && hasResolvedActiveIteration); + const hasActiveDescendant = useMemo( + () => timelineItemsContainActiveElement(subItems, activeItem), + [subItems, activeItem], + ); + // Deep-link case: `?active=&iteration=N`. The loop block is the + // selected item, but `isActiveBlock` is intentionally suppressed (the + // iteration row owns the highlight) and `hasActiveDescendant` is false + // (the loop isn't its own child). Without this, the loop stays collapsed + // and the targeted iteration row is hidden. + const isLoopWithSelectedIteration = + isLoopBlock && + hasResolvedActiveIteration && + isWorkflowRunBlock(activeItem) && + activeItem.workflow_run_block_id === block.workflow_run_block_id; + + const [expanded, setExpanded] = useState( + isRunning || + isActiveBlock || + hasActions || + hasActiveDescendant || + isLoopWithSelectedIteration || + !hasRenderableNestedChildren, + ); + const userToggledRef = useRef(false); - // When the active element changes (or loop groups load for the first time), - // auto-expand the group that contains it. Also ensure the most-recent group - // (index 0) is open — this covers the edge case where subItems loads after - // the initial mount so the useState initializer missed it. useEffect(() => { - setOpenLoopGroups((prev) => { - let next = prev; - // Ensure most-recent group is open (covers late-loading data). - if (loopIterationGroups.length > 0 && !prev.has(0)) { - next = new Set(next); - next.add(0); - } - // Find and expand the group containing the active item. - for (let idx = 0; idx < loopIterationGroups.length; idx++) { - const group = loopIterationGroups[idx]; - if ( - group && - timelineItemsContainActiveElement(group.items, activeItem) - ) { - if (!next.has(idx)) { - if (next === prev) next = new Set(next); - next.add(idx); - } - break; // active item can only be in one group - } - } - return next; - }); - }, [activeItem, loopIterationGroups]); + userToggledRef.current = false; + }, [block.workflow_run_block_id]); - // Auto-expand conditional children when the active element is inside them. + // Auto-expand when actions appear, an active descendant appears, or this + // block starts running. + // Skip once the user has explicitly toggled the chevron — their choice wins. useEffect(() => { + if (userToggledRef.current) return; if ( - isConditionalBlock && - hasNestedChildren && - timelineItemsContainActiveElement(subItems, activeItem) + hasActions || + hasActiveDescendant || + isRunning || + isLoopWithSelectedIteration ) { - setChildrenOpen(true); + setExpanded(true); } - }, [activeItem, isConditionalBlock, hasNestedChildren, subItems]); + }, [hasActions, hasActiveDescendant, isRunning, isLoopWithSelectedIteration]); const loopValues = Array.isArray(block.loop_values) ? block.loop_values : []; + // Loop inline counter (e.g. 3/8). + const loopCounter = isForLoopBlock + ? loopValues.length > 0 + ? `${loopIterationGroups.length}/${loopValues.length}` + : null + : isWhileLoopBlock + ? `${loopIterationGroups.length}` + : null; + return ( -
0, - "border-l border-neutral-300 dark:border-slate-700": depth > 0, - })} - > -
{ - event.stopPropagation(); - onBlockItemClick(block); - }} - ref={refCallback} - > -
-
-
-
-
- - {isRunning && ( -
- -
- )} -
- -
- - {workflowBlockTitle[block.block_type]} - - - {block.label} - - {isFinallyBlock && ( - - Execute on any outcome - - )} -
-
-
- {showFailureIndicator && ( -
- -
- )} - {showSuccessIndicator && ( -
- -
- )} -
-
- {showDiagnosticLink ? ( - event.stopPropagation()} - > -
- - Diagnostics -
- - ) : ( - <> - - Block - - )} -
- {duration && showDuration && ( -
- {duration} -
- )} -
-
-
- {block.description ? ( -
- {block.description} -
- ) : null} - {isForLoopBlock && ( -
-
- Iterable values:{" "} - - {loopValues.length} - -
- {loopValues.length > 0 && ( -
- {loopValues.map((value, index) => { - const fullValue = stringifyTimelineValue(value); - const collapsed = fullValue.replace(/\s+/g, " ").trim(); - return ( -
- - [{index}] - - -
- ); - })} -
- )} -
- )} - {isWhileLoopBlock && ( -
- Iterations run:{" "} - - {loopIterationGroups.length} - -
- )} - {block.block_type === "conditional" && block.executed_branch_id && ( -
- {hasEvaluations(block.output) && block.output.evaluations ? ( - // New format: show all branch evaluations -
- {block.output.evaluations.map((evaluation, index) => ( -
- {evaluation.is_default ? ( -
- Default branch - {evaluation.is_matched && ( - - ✓ Matched - - )} -
- ) : ( -
-
- - {evaluation.original_expression} - -
- {evaluation.rendered_expression && - evaluation.rendered_expression !== - evaluation.original_expression && ( -
- → rendered to{" "} - - {evaluation.rendered_expression} - -
- )} -
- - evaluated to - - - {evaluation.result ? "True" : "False"} - - {evaluation.is_matched && ( - ✓ Matched - )} -
-
- )} - {evaluation.is_matched && - evaluation.next_block_label && ( -
- → Executing next block:{" "} - - {evaluation.next_block_label} - -
- )} -
- ))} -
- ) : ( - // Fallback: old format without evaluations array - <> - {block.executed_branch_expression !== null && - block.executed_branch_expression !== undefined ? ( -
- Condition{" "} - - {block.executed_branch_expression} - {" "} - evaluated to{" "} - True -
- ) : ( -
- No conditions matched, executing default branch -
- )} - {block.executed_branch_next_block && ( -
- → Executing next block:{" "} - - {block.executed_branch_next_block} - -
- )} - - )} -
- )} -
- - {block.block_type === "human_interaction" && ( - +
+
+ +
{ - const isActive = - isAction(activeItem) && activeItem.action_id === action.action_id; - const displayIndex = actions.length - index; - if (isViewingV2) { - return ( -
event.stopPropagation()} - > - { - setExpandedActionId((prev) => - prev === action.action_id ? null : action.action_id, - ); - }} - onSelect={() => { - onActionClick({ block, action }); - }} - cardClassName={getCardElevation(depth)} - /> -
- ); - } - return ( - { - event.stopPropagation(); - const actionItem: ActionItem = { - block, - action, - }; - onActionClick(actionItem); - }} - cardClassName={getCardElevation(depth)} + style={railHighlightStyle} + > + {isContainer ? ( + + ) : ( +
+ )} + +
+
- {hasNestedChildren && isLoopBlock && ( -
- {loopIterationGroups.map((group, groupIndex) => { - const loopValueFromIterable = - group.index !== null - ? (loopValues[group.index] ?? null) - : null; - const iterationNumber = - group.index !== null ? group.index + 1 : groupIndex + 1; - const currentValueFull = stringifyTimelineValue( - loopValueFromIterable ?? group.currentValue, - ); - const currentValuePreview = truncateValue( - currentValueFull, - 140, - ); - const isValueTruncated = - currentValuePreview !== - currentValueFull.replace(/\s+/g, " ").trim(); - - return ( - { - setOpenLoopGroups((prev) => { - const next = new Set(prev); - if (open) { - next.add(groupIndex); - } else { - next.delete(groupIndex); - } - return next; - }); - }} - > -
- - - -
- - - -
- ); - })} -
- )} - - {hasNestedChildren && isConditionalBlock && ( - -
- - - -
- + {/* Container body — always mounted so open/close transitions animate */} + {isContainer && ( + + + {hasActions && ( + + )} + {isLoopBlock && loopIterationGroups.length > 0 ? ( + + ) : ( + hasRenderableNestedChildren && ( - - - )} + ) + )} + +
+ )} +
+ ); +} - {hasNestedChildren && !isLoopBlock && !isConditionalBlock && ( - +type LoopIterationRowsProps = { + loopBlock: WorkflowRunBlock; + groups: Array; + activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; + depth: number; + blockOrder?: ReadonlyMap; + onBlockItemClick: (block: WorkflowRunBlock) => void; + onIterationClick?: ( + loopBlock: WorkflowRunBlock, + iterationIndex: number, + ) => void; + onActionClick: (action: ActionItem) => void; + onThoughtClick?: (thought: ObserverThought) => void; + renderThoughts?: boolean; + finallyBlockLabel?: string | null; + workflowRunIsFinalized?: boolean; +}; + +function LoopIterationRows({ + loopBlock, + groups, + activeItem, + activeIteration = null, + depth, + blockOrder, + onBlockItemClick, + onIterationClick, + onActionClick, + onThoughtClick, + renderThoughts = false, + finallyBlockLabel, + workflowRunIsFinalized, +}: LoopIterationRowsProps) { + return ( +
+ {groups.map((group, groupIndex) => ( + // Key by group.index alone so existing iteration rows keep their + // identity (and useState) when a new iteration arrives at the top + // of the DESC-sorted list. Including groupIndex would shift every + // existing row's key and remount the whole stack on each update. + + ))} +
+ ); +} + +type LoopIterationRowProps = { + loopBlock: WorkflowRunBlock; + group: LoopIterationGroup; + groupIndex: number; + groupCount: number; + activeItem: WorkflowRunOverviewActiveElement; + activeIteration?: number | null; + depth: number; + blockOrder?: ReadonlyMap; + onBlockItemClick: (block: WorkflowRunBlock) => void; + onIterationClick?: ( + loopBlock: WorkflowRunBlock, + iterationIndex: number, + ) => void; + onActionClick: (action: ActionItem) => void; + onThoughtClick?: (thought: ObserverThought) => void; + renderThoughts?: boolean; + finallyBlockLabel?: string | null; + workflowRunIsFinalized?: boolean; +}; + +function LoopIterationRow({ + loopBlock, + group, + groupIndex, + groupCount, + activeItem, + activeIteration = null, + depth, + blockOrder, + onBlockItemClick, + onIterationClick, + onActionClick, + onThoughtClick, + renderThoughts = false, + finallyBlockLabel, + workflowRunIsFinalized, +}: LoopIterationRowProps) { + const hasActiveDescendant = useMemo( + () => timelineItemsContainActiveElement(group.items, activeItem), + [group.items, activeItem], + ); + const status = useMemo( + () => aggregateIterationStatus(group.items), + [group.items], + ); + + // Default open: latest group, running, or has active. + const [expanded, setExpanded] = useState( + groupIndex === groupCount - 1 || + hasActiveDescendant || + status === Status.Running, + ); + const userToggledRef = useRef(false); + + useEffect(() => { + userToggledRef.current = false; + }, [loopBlock.workflow_run_block_id, group.index]); + + // Mirror the block-row pattern: auto-expand when status flips to running + // or an active descendant appears, unless the user has explicitly + // collapsed this row. + useEffect(() => { + if (userToggledRef.current) return; + if (hasActiveDescendant || status === Status.Running) { + setExpanded(true); + } + }, [hasActiveDescendant, status]); + + const iterationIndex = group.index !== null ? group.index : groupIndex; + const iterationNumber = iterationIndex + 1; + const currentValuePreview = normalizeInlineText(group.currentValue); + const isActiveIteration = + isWorkflowRunBlock(activeItem) && + activeItem.workflow_run_block_id === loopBlock.workflow_run_block_id && + activeIteration === iterationIndex; + + return ( +
+
+ +
+ +
+ + + + +
); } diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.test.tsx new file mode 100644 index 000000000..3702532fc --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Status } from "@/api/types"; +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { BlockDetailConditional } from "./BlockDetailConditional"; + +function buildConditional( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_cond", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "conditional", + label: "validate_npiexist", + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: null, + current_value: null, + current_index: null, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("BlockDetailConditional", () => { + it("renders each evaluation's expression, rendered form, and matched indicator", () => { + const block = buildConditional({ + executed_branch_id: "b_default", + output: { + evaluations: [ + { + branch_id: "b_true", + branch_index: 0, + criteria_type: "jinja2_template", + original_expression: "{{ result == true }}", + rendered_expression: "false == true", + result: false, + is_matched: false, + is_default: false, + next_block_label: "next_true", + error: null, + }, + { + branch_id: "b_default", + branch_index: 1, + criteria_type: null, + original_expression: null, + rendered_expression: null, + result: null, + is_matched: true, + is_default: true, + next_block_label: "fallback", + error: null, + }, + ], + }, + }); + + render(); + // The unmatched branch's expression renders as-is + expect(screen.getByText("{{ result == true }}")).toBeDefined(); + // The rendered expression renders separately when it differs + expect(screen.getByText("false == true")).toBeDefined(); + // The default branch label is announced for branches without an expression + expect(screen.getByText(/default branch/i)).toBeDefined(); + // The matched branch shows the next block target + expect(screen.getByText("fallback")).toBeDefined(); + }); + + it("falls back to the legacy executed_branch_expression rendering when no evaluations array", () => { + const block = buildConditional({ + executed_branch_id: "b_match", + executed_branch_expression: "{{ x == 1 }}", + executed_branch_result: true, + }); + + render(); + expect(screen.getByText(/evaluation/i)).toBeDefined(); + expect(screen.getByText("{{ x == 1 }}")).toBeDefined(); + }); + + it("renders valid JSON rendered branch values with the JSON explorer", () => { + const block = buildConditional({ + executed_branch_id: "b_json", + output: { + evaluations: [ + { + branch_id: "b_json", + branch_index: 0, + criteria_type: "jinja2_template", + original_expression: "{{ response }}", + rendered_expression: + '{"status_code":200,"response_headers":{"X-Stage":"signin"}}', + result: true, + is_matched: true, + is_default: false, + next_block_label: "next_block", + error: null, + }, + ], + }, + }); + + render(); + + expect(screen.getByText("rendered")).toBeDefined(); + expect(screen.getByText("status_code")).toBeDefined(); + expect(screen.getByText("200")).toBeDefined(); + expect(screen.getByText(/X-Stage.*signin/)).toBeDefined(); + expect(screen.queryByText(/Object\(\d+\)/)).toBeNull(); + expect(screen.getByPlaceholderText("Search JSON")).toBeDefined(); + }); + + it("renders a clear message when the default branch executed and no expression matched", () => { + const block = buildConditional({ + executed_branch_id: "b_default", + executed_branch_expression: null, + executed_branch_result: null, + }); + render(); + expect(screen.getByText(/no conditions matched/i)).toBeDefined(); + }); + + it("renders no evaluation/branches section before the conditional has resolved a branch", () => { + const block = buildConditional({ + status: Status.Running, + executed_branch_id: null, + executed_branch_expression: null, + executed_branch_result: null, + }); + render(); + // Both the "Branches" and "Evaluation" sections should stay hidden + expect(screen.queryByText(/branches/i)).toBeNull(); + expect(screen.queryByText(/^evaluation/i)).toBeNull(); + expect(screen.queryByText(/no conditions matched/i)).toBeNull(); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.tsx new file mode 100644 index 000000000..28dd13415 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailConditional.tsx @@ -0,0 +1,157 @@ +import { + hasEvaluations, + type WorkflowRunBlock, +} from "../../types/workflowRunTypes"; +import { JsonExplorer } from "./BlockInspector"; +import { BlockDetailFailure, CodeBlock, Section } from "./shared"; +import { cn } from "@/util/utils"; + +type Props = { + block: WorkflowRunBlock; +}; + +function tryParseJson(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function RenderedExpression({ value }: { value: string }) { + const parsedJson = tryParseJson(value); + if (parsedJson !== null) { + return ; + } + return ( + + {value} + + ); +} + +function BlockDetailConditional({ block }: Props) { + const evaluations = + hasEvaluations(block.output) && block.output.evaluations + ? block.output.evaluations + : null; + // Gate evaluation/branch rendering on the conditional having actually + // resolved a branch. Before that (Created/Queued/Running), claiming a + // result — especially the "executed default branch" fallback — is wrong. + const hasExecutedBranch = Boolean(block.executed_branch_id); + + return ( +
+ + {hasExecutedBranch && evaluations && evaluations.length > 0 ? ( +
+
+ {evaluations.map((evaluation, index) => ( +
+ {evaluation.is_default ? ( +
+ Default branch + {evaluation.is_matched && ( + ✓ Matched + )} +
+ ) : ( +
+
+ + Expression + +
+ + {evaluation.original_expression} + +
+
+ {evaluation.rendered_expression && + evaluation.rendered_expression !== + evaluation.original_expression && ( +
+ + Rendered + +
+ +
+
+ )} +
+ Result: + + {evaluation.result ? "True" : "False"} + + {evaluation.is_matched && ( + ✓ Matched + )} +
+
+ )} + {evaluation.is_matched && evaluation.next_block_label && ( +
+ → Next:{" "} + + {evaluation.next_block_label} + +
+ )} +
+ ))} +
+
+ ) : hasExecutedBranch ? ( +
+ {block.executed_branch_expression ? ( +
+
+ Expression: + + {block.executed_branch_expression} + +
+
+ Result: + + {block.executed_branch_result ? "True" : "False"} + +
+
+ ) : ( +
+ No conditions matched — executed default branch. +
+ )} +
+ ) : null} +
+ ); +} + +export { BlockDetailConditional }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailGeneric.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailGeneric.tsx new file mode 100644 index 000000000..35cbc1d6c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailGeneric.tsx @@ -0,0 +1,26 @@ +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { BlockDetailFailure, GoalText, Section } from "./shared"; + +type Props = { + block: WorkflowRunBlock; +}; + +function BlockDetailGeneric({ block }: Props) { + return ( +
+ + {block.prompt && ( +
+ +
+ )} + {block.body && ( +
+ +
+ )} +
+ ); +} + +export { BlockDetailGeneric }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHeader.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHeader.test.tsx new file mode 100644 index 000000000..bf77446c2 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHeader.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Status } from "@/api/types"; +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { BlockDetailHeader } from "./shared"; + +function buildBlock( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_default", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "task", + label: null, + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: null, + current_value: null, + current_index: null, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("BlockDetailHeader iterated-value chip", () => { + it("shows the block id beside the block label", () => { + const block = buildBlock({ + workflow_run_block_id: "wrb_531711929286793050", + block_type: "http_request", + label: "signin", + }); + + render(); + + expect(screen.getByText("HTTP Request")).toBeDefined(); + expect(screen.getByText("signin")).toBeDefined(); + expect(screen.getByText("wrb_531711929286793050")).toBeDefined(); + }); + + it("shows loop_values[iterationOverride] on a for_loop with an explicit iteration selection", () => { + const block = buildBlock({ + block_type: "for_loop", + loop_values: ["alpha", "beta", "gamma"], + // Backend mirrors the latest iteration on the loop block itself; we + // should NOT use this when an older iteration is selected. + current_value: "gamma", + current_index: 2, + }); + + render(); + + expect(screen.getByText(/Iteration 2/)).toBeDefined(); + expect(screen.getByText("Iterated value:")).toBeDefined(); + expect(screen.getByText("beta")).toBeDefined(); + expect(screen.queryByText("gamma")).toBeNull(); + }); + + it("falls back to current_value on a loop block with no iteration override", () => { + const block = buildBlock({ + block_type: "for_loop", + loop_values: ["alpha", "beta", "gamma"], + current_value: "gamma", + current_index: 2, + }); + + render(); + + expect(screen.getByText(/Iteration 3/)).toBeDefined(); + expect(screen.getByText("gamma")).toBeDefined(); + }); + + it("hides iteration context on a while_loop with an iteration override but no loop_values", () => { + const block = buildBlock({ + block_type: "while_loop", + loop_values: null, + // Backend may still mirror a latest value on the loop block; rather + // than display it under a mismatched iteration label, render nothing. + current_value: "latest", + current_index: 4, + }); + + render(); + + expect(screen.queryByText(/Iteration 3/)).toBeNull(); + expect(screen.queryByText("Iterated value:")).toBeNull(); + expect(screen.queryByText("latest")).toBeNull(); + }); + + it("hides iteration context when an explicit loop iteration is out of range", () => { + const block = buildBlock({ + block_type: "for_loop", + loop_values: ["alpha"], + current_value: "alpha", + current_index: 0, + }); + + render(); + + expect(screen.queryByText(/Iteration 100/)).toBeNull(); + expect(screen.queryByText("Iterated value:")).toBeNull(); + expect(screen.queryByText("alpha")).toBeNull(); + }); + + it("renders a non-loop child block's current_value as-is", () => { + const block = buildBlock({ + block_type: "task", + current_value: "alpha", + current_index: 0, + }); + + render(); + + expect(screen.getByText(/Iteration 1/)).toBeDefined(); + expect(screen.getByText("alpha")).toBeDefined(); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHttpRequest.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHttpRequest.tsx new file mode 100644 index 000000000..733dda4d1 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHttpRequest.tsx @@ -0,0 +1,27 @@ +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { JsonExplorer } from "./BlockInspector"; +import { BlockDetailFailure, Section } from "./shared"; + +type Props = { + block: WorkflowRunBlock; +}; + +function BlockDetailHttpRequest({ block }: Props) { + return ( +
+ + {block.url && ( +
+ {block.url} +
+ )} + {block.output !== null && block.output !== undefined && ( +
+ +
+ )} +
+ ); +} + +export { BlockDetailHttpRequest }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHumanInteraction.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHumanInteraction.tsx new file mode 100644 index 000000000..a8355ddf3 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailHumanInteraction.tsx @@ -0,0 +1,42 @@ +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { WorkflowRunHumanInteraction } from "../WorkflowRunHumanInteraction"; +import { BlockDetailFailure, GoalText, Section } from "./shared"; + +type Props = { + block: WorkflowRunBlock; +}; + +function BlockDetailHumanInteraction({ block }: Props) { + const recipients = block.recipients ?? []; + return ( +
+ + {block.instructions && ( +
+ +
+ )} + {block.subject && ( +
+ + {block.subject} + +
+ )} + {recipients.length > 0 && ( +
+
    + {recipients.map((address) => ( +
  • + {address} +
  • + ))} +
+
+ )} + +
+ ); +} + +export { BlockDetailHumanInteraction }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.test.tsx new file mode 100644 index 000000000..d109a29f6 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Status } from "@/api/types"; +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { BlockDetailLoop } from "./BlockDetailLoop"; + +function buildLoopBlock( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_loop", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "for_loop", + label: "iterate_items", + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: [ + { name: "alpha", value: 1 }, + { name: "beta", value: 2 }, + { name: "gamma", value: 3 }, + ], + current_value: null, + current_index: null, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("BlockDetailLoop", () => { + it("shows the full iterable list when no iteration is selected", () => { + render(); + // The Iterable values section header includes the count + expect(screen.getByText(/iterable values \(3\)/i)).toBeDefined(); + // All three values render as code blocks + expect(screen.getByText(/"name": "alpha"/)).toBeDefined(); + expect(screen.getByText(/"name": "beta"/)).toBeDefined(); + expect(screen.getByText(/"name": "gamma"/)).toBeDefined(); + }); + + it("hides the full iterable list when a specific iteration is selected", () => { + render(); + // The iterable list header should not appear + expect(screen.queryByText(/iterable values \(3\)/i)).toBeNull(); + }); + + it("shows the selected iteration's value section when iterationIndex is set", () => { + render(); + expect(screen.getByText(/iteration 3 value/i)).toBeDefined(); + expect(screen.getByText(/"name": "gamma"/)).toBeDefined(); + // alpha and beta should not render in this view + expect(screen.queryByText(/"name": "alpha"/)).toBeNull(); + expect(screen.queryByText(/"name": "beta"/)).toBeNull(); + }); + + it("falls back to the loop block's own current_index when no iteration override", () => { + render( + , + ); + // current iteration section: 2 of 3 + expect(screen.getByText(/current iteration/i)).toBeDefined(); + expect(screen.getByText(/2 of 3/)).toBeDefined(); + }); + + it("falls back to the default loop view when iterationIndex is out of range", () => { + // Loop has 3 values; URL says iteration=99. The iteration-only section + // can't render (no value at index 99), and the default view must take + // over so the body isn't empty. + render( + , + ); + // Iteration-specific section should NOT render + expect(screen.queryByText(/iteration 100 value/i)).toBeNull(); + // Default view renders instead + expect(screen.getByText(/iterable values \(3\)/i)).toBeDefined(); + expect(screen.getByText(/current iteration/i)).toBeDefined(); + }); + + it("shows the selected while_loop iteration even without loop_values", () => { + const whileBlock = buildLoopBlock({ + block_type: "while_loop", + loop_values: null, + current_index: 3, + }); + render(); + expect(screen.queryByText(/iteration 3 value/i)).toBeNull(); + expect(screen.getByText(/selected iteration/i)).toBeDefined(); + expect(screen.getByText("3")).toBeDefined(); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.tsx new file mode 100644 index 000000000..e755fa385 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailLoop.tsx @@ -0,0 +1,82 @@ +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { stringifyTimelineValue } from "./formatValue"; +import { BlockDetailFailure, CodeBlock, Section } from "./shared"; + +type Props = { + block: WorkflowRunBlock; + iterationIndex?: number | null; +}; + +function BlockDetailLoop({ block, iterationIndex = null }: Props) { + const loopValues = Array.isArray(block.loop_values) ? block.loop_values : []; + const isForLoop = block.block_type === "for_loop"; + const highlightIndex = + iterationIndex !== null && iterationIndex >= 0 ? iterationIndex : null; + const hasResolvedForLoopIteration = + isForLoop && highlightIndex !== null && highlightIndex < loopValues.length; + const highlightedValue = hasResolvedForLoopIteration + ? loopValues[highlightIndex] + : null; + + // Show the iteration-only view only when we can actually resolve a value + // for the selected iteration. `null` is a valid loop value, so gate on + // index resolvability rather than truthiness. + const showIterationOnly = hasResolvedForLoopIteration; + const showSelectedWhileLoopIteration = !isForLoop && highlightIndex !== null; + const displayedIteration = showSelectedWhileLoopIteration + ? highlightIndex + : block.current_index; + + return ( +
+ + {showIterationOnly && ( +
+ {stringifyTimelineValue(highlightedValue)} +
+ )} + {!showIterationOnly && isForLoop && ( +
+ {loopValues.length > 0 ? ( +
+ {loopValues.map((value, index) => { + const full = stringifyTimelineValue(value); + return ( +
+ + [{index}] + + {full} +
+ ); + })} +
+ ) : ( +
No values.
+ )} +
+ )} + {!showIterationOnly && displayedIteration !== null && ( +
+ + {displayedIteration + 1} + {isForLoop && loopValues.length > 0 + ? ` of ${loopValues.length}` + : ""} + +
+ )} +
+ ); +} + +export { BlockDetailLoop }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.test.tsx new file mode 100644 index 000000000..ef2edb77b --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom + +vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ActionTypes, Status, type ActionsApiResponse } from "@/api/types"; +import type { + ObserverThought, + WorkflowRunBlock, +} from "../../types/workflowRunTypes"; +import { BlockDetailTask } from "./BlockDetailTask"; + +function buildTaskBlock( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_task", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "task", + label: "task_block", + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: null, + current_value: null, + current_index: null, + ...overrides, + }; +} + +function buildThought( + overrides: Partial = {}, +): ObserverThought { + return { + thought_id: "thg_1", + observer_cruise_id: "oc_default", + organization_id: "org_default", + user_input: null, + observation: null, + thought: "Considering the next step", + answer: null, + workflow_run_id: "wr_default", + workflow_id: "w_default", + workflow_permanent_id: "wpid_default", + workflow_run_block_id: "wrb_task", + task_id: null, + created_at: "2026-01-01T00:00:01Z", + modified_at: "2026-01-01T00:00:01Z", + ...(overrides as Partial), + } as ObserverThought; +} + +function buildAction( + overrides: Partial = {}, +): ActionsApiResponse { + return { + action_id: "act_default", + action_type: ActionTypes.wait, + status: Status.Completed, + reasoning: null, + text: null, + response: null, + confidence_float: null, + created_by: null, + ...overrides, + } as ActionsApiResponse; +} + +afterEach(() => { + cleanup(); +}); + +describe("BlockDetailTask thought selection", () => { + it("fires onThoughtSelect when a thought card is clicked", () => { + const onThoughtSelect = vi.fn(); + const thought = buildThought({ + thought_id: "thg_42", + thought: "Picking the right button", + }); + + render( + , + ); + + fireEvent.click(screen.getByText("Picking the right button")); + + expect(onThoughtSelect).toHaveBeenCalledTimes(1); + expect(onThoughtSelect).toHaveBeenCalledWith(thought); + }); + + it("renders thought cards without crashing when no onThoughtSelect is given", () => { + render( + , + ); + expect(screen.getByText("Considering the next step")).toBeDefined(); + }); + + it("renders actions in chronological order to match their numbers", () => { + const newestFirstActions = [ + buildAction({ action_id: "act_3", reasoning: "third action" }), + buildAction({ action_id: "act_2", reasoning: "second action" }), + buildAction({ action_id: "act_1", reasoning: "first action" }), + ]; + + render( + , + ); + + const firstAction = screen.getByText("first action"); + const thirdAction = screen.getByText("third action"); + expect( + firstAction.compareDocumentPosition(thirdAction) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.tsx new file mode 100644 index 000000000..c52f7f193 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailTask.tsx @@ -0,0 +1,48 @@ +import type { ActionsApiResponse } from "@/api/types"; +import type { + ObserverThought, + WorkflowRunBlock, +} from "../../types/workflowRunTypes"; +import type { WorkflowRunOverviewActiveElement } from "../WorkflowRunOverview"; +import { + BlockActionList, + BlockDetailFailure, + BlockThoughtList, +} from "./shared"; + +type Props = { + block: WorkflowRunBlock; + activeItem: WorkflowRunOverviewActiveElement; + thoughts?: Array; + onActionSelect?: (payload: { + block: WorkflowRunBlock; + action: ActionsApiResponse; + }) => void; + onThoughtSelect?: (thought: ObserverThought) => void; +}; + +function BlockDetailTask({ + block, + activeItem, + thoughts = [], + onActionSelect, + onThoughtSelect, +}: Props) { + return ( +
+ + + +
+ ); +} + +export { BlockDetailTask }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailThought.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailThought.tsx new file mode 100644 index 000000000..2f73a059d --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailThought.tsx @@ -0,0 +1,35 @@ +import type { ObserverThought } from "../../types/workflowRunTypes"; +import { GoalText, Section } from "./shared"; + +type Props = { + thought: ObserverThought; +}; + +function BlockDetailThought({ thought }: Props) { + return ( +
+ {thought.user_input && ( +
+ +
+ )} + {thought.observation && ( +
+ +
+ )} + {thought.thought && ( +
+ +
+ )} + {thought.answer && ( +
+ +
+ )} +
+ ); +} + +export { BlockDetailThought }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailWorkflowTrigger.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailWorkflowTrigger.tsx new file mode 100644 index 000000000..d3467c331 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockDetailWorkflowTrigger.tsx @@ -0,0 +1,72 @@ +import { Link } from "react-router-dom"; +import type { WorkflowRunBlock } from "../../types/workflowRunTypes"; +import { BlockDetailFailure, JsonView, Section } from "./shared"; + +type Props = { + block: WorkflowRunBlock; +}; + +type SubWorkflowRun = { + workflow_run_id?: string; + workflow_permanent_id?: string; + status?: string; + failure_reason?: string | null; + outputs?: unknown; +}; + +function asSubWorkflowRun(value: unknown): SubWorkflowRun | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as SubWorkflowRun; +} + +function BlockDetailWorkflowTrigger({ block }: Props) { + const subRun = asSubWorkflowRun(block.output); + const hasSubRunOutput = + subRun !== null && + subRun.outputs !== undefined && + subRun.outputs !== null && + !( + typeof subRun.outputs === "object" && + !Array.isArray(subRun.outputs) && + Object.keys(subRun.outputs as object).length === 0 + ); + + return ( +
+ + {subRun?.workflow_run_id && ( +
+
+ + {subRun.workflow_run_id} + + {subRun.status && ( + + · {subRun.status} + + )} +
+
+ )} + {subRun?.failure_reason && ( +
+
+ {subRun.failure_reason} +
+
+ )} + {hasSubRunOutput && ( +
+ +
+ )} +
+ ); +} + +export { BlockDetailWorkflowTrigger }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.test.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.test.tsx new file mode 100644 index 000000000..7e2a972fa --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { JsonExplorer } from "./BlockInspector"; + +afterEach(() => { + cleanup(); +}); + +describe("JsonExplorer", () => { + it("previews nested objects with key/value content instead of object counts", () => { + render( + , + ); + + expect(screen.queryByText(/Object\(/)).toBeNull(); + expect(screen.getByText(/loop_value: "\/runs"/)).toBeDefined(); + expect( + screen.getByText(/output_parameter: \{ name: "run_id" \}/), + ).toBeDefined(); + }); + + it("hides compact previews once an expandable section is open", () => { + render( + , + ); + + const row = screen.getByRole("button", { + name: /0.*loop_value.*\/runs/i, + }); + expect(row.textContent).toContain('loop_value: "/runs"'); + + fireEvent.click(row); + + expect(row.textContent).not.toContain('loop_value: "/runs"'); + expect(screen.getByText("loop_value")).toBeDefined(); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.tsx new file mode 100644 index 000000000..ff66608f7 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/BlockInspector.tsx @@ -0,0 +1,435 @@ +import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { useEffect, useMemo, useState } from "react"; + +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Status } from "@/api/types"; +import { cn } from "@/util/utils"; +import { + hasExtractedInformation, + type WorkflowRunBlock, +} from "../../types/workflowRunTypes"; +import { isTaskVariantBlock } from "../../types/workflowTypes"; + +type InspectorField = { + label: string; + value: unknown; + kind?: "json" | "text"; +}; + +type JsonExplorerProps = { + value: unknown; + rootLabel?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined || value === "") return true; + if (Array.isArray(value)) return value.length === 0; + if (isRecord(value)) return Object.keys(value).length === 0; + return false; +} + +function valueMatchesSearch(value: unknown, search: string): boolean { + if (!search) return true; + if (Array.isArray(value)) { + return value.some((child) => valueMatchesSearch(child, search)); + } + if (isRecord(value)) { + return Object.entries(value).some( + ([key, childValue]) => + key.toLowerCase().includes(search) || + valueMatchesSearch(childValue, search), + ); + } + return primitivePreview(value).toLowerCase().includes(search); +} + +function nodeOwnTextMatchesSearch( + label: string, + value: unknown, + search: string, +) { + if (!search) return false; + if (label.toLowerCase().includes(search)) return true; + if (Array.isArray(value) || isRecord(value)) return false; + return primitivePreview(value).toLowerCase().includes(search); +} + +function HighlightedText({ text, search }: { text: string; search: string }) { + if (!search) return <>{text}; + + const lowerText = text.toLowerCase(); + const parts: Array<{ text: string; matched: boolean }> = []; + let cursor = 0; + let matchIndex = lowerText.indexOf(search, cursor); + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + parts.push({ text: text.slice(cursor, matchIndex), matched: false }); + } + const matchEnd = matchIndex + search.length; + parts.push({ text: text.slice(matchIndex, matchEnd), matched: true }); + cursor = matchEnd; + matchIndex = lowerText.indexOf(search, cursor); + } + + if (cursor < text.length) { + parts.push({ text: text.slice(cursor), matched: false }); + } + + return ( + <> + {parts.map((part, index) => + part.matched ? ( + + {part.text} + + ) : ( + {part.text} + ), + )} + + ); +} + +function FieldValue({ field }: { field: InspectorField }) { + if (field.kind === "json" || typeof field.value === "object") { + return ; + } + return ( +
+ {String(field.value)} +
+ ); +} + +function FieldList({ + fields, + emptyText, +}: { + fields: Array; + emptyText: string; +}) { + if (fields.length === 0) { + return
{emptyText}
; + } + return ( +
+ {fields.map((field) => ( +
+
+ {field.label} +
+ +
+ ))} +
+ ); +} + +function primitivePreview(value: unknown): string { + if (typeof value === "string") return `"${value}"`; + if (value === null) return "null"; + return String(value); +} + +function truncatePreview(value: string, maxLength = 72): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 1)}…`; +} + +function objectPreview(value: Record): string { + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + + const preview = entries + .slice(0, 3) + .map(([key, childValue]) => `${key}: ${compactValuePreview(childValue)}`) + .join(", "); + return `{ ${preview}${entries.length > 3 ? ", ..." : ""} }`; +} + +function compactValuePreview(value: unknown): string { + if (Array.isArray(value)) { + return `${value.length} ${value.length === 1 ? "entry" : "entries"}`; + } + if (isRecord(value)) return objectPreview(value); + return primitivePreview(value); +} + +function expandablePreview(value: unknown, childCount: number): string { + if (Array.isArray(value)) { + const preview = value + .slice(0, 3) + .map((child) => compactValuePreview(child)) + .join(", "); + return truncatePreview( + `${childCount} ${childCount === 1 ? "entry" : "entries"}${preview ? ` [${preview}]` : ""}`, + ); + } + + if (isRecord(value)) { + if (childCount === 0) return "{}"; + const preview = Object.entries(value) + .slice(0, 3) + .map(([key, childValue]) => `${key}: ${compactValuePreview(childValue)}`) + .join(", "); + return truncatePreview(`{ ${preview}${childCount > 3 ? ", ..." : ""} }`); + } + + return compactValuePreview(value); +} + +function JsonNode({ + label, + value, + path, + search, + expanded, + onToggle, +}: { + label: string; + value: unknown; + path: string; + search: string; + expanded: ReadonlySet; + onToggle: (path: string) => void; +}) { + const isArray = Array.isArray(value); + const isObject = isRecord(value); + const isExpandable = isArray || isObject; + const children = isArray + ? value.map((child, index) => [String(index), child] as const) + : isObject + ? Object.entries(value) + : []; + const hasSearch = search.length > 0; + const ownMatches = nodeOwnTextMatchesSearch(label, value, search); + const descendantMatches = + (isArray || isObject) && valueMatchesSearch(value, search); + const matches = ownMatches || descendantMatches; + if (hasSearch && !matches) return null; + + const isOpen = hasSearch || expanded.has(path); + const childCount = children.length; + + if (!isExpandable) { + return ( +
+
+ ); + } + + return ( +
+ + {isOpen && ( +
+ {children.map(([childKey, childValue]) => ( + + ))} +
+ )} +
+ ); +} + +function JsonExplorer({ value, rootLabel = "value" }: JsonExplorerProps) { + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(() => new Set(["$"])); + const normalizedSearch = search.trim().toLowerCase(); + const rootIsExpandable = Array.isArray(value) || isRecord(value); + + function toggle(path: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + } + + return ( +
+ {rootIsExpandable && ( + setSearch(event.target.value)} + placeholder="Search JSON" + className="h-7 border-slate-700 bg-slate-elevation2 text-xs" + /> + )} +
+ +
+
+ ); +} + +function pushField( + fields: Array, + label: string, + value: unknown, + kind?: InspectorField["kind"], +) { + if (isEmptyValue(value)) return; + fields.push({ label, value, kind }); +} + +function getInputFields(block: WorkflowRunBlock): Array { + const fields: Array = []; + pushField(fields, "Description", block.description); + pushField(fields, "Prompt", block.prompt); + pushField(fields, "URL", block.url); + pushField(fields, "Navigation goal", block.navigation_goal); + pushField(fields, "Extraction goal", block.data_extraction_goal); + pushField(fields, "Data schema", block.data_schema, "json"); + pushField(fields, "Complete criterion", block.complete_criterion); + pushField(fields, "Terminate criterion", block.terminate_criterion); + pushField(fields, "Loop values", block.loop_values, "json"); + pushField(fields, "Current value", block.current_value); + pushField(fields, "Instructions", block.instructions); + pushField(fields, "Subject", block.subject); + pushField(fields, "Recipients", block.recipients, "json"); + pushField(fields, "Body", block.body); + pushField(fields, "Wait seconds", block.wait_sec); + pushField(fields, "HTTP method", block.method); + pushField(fields, "Headers", block.headers, "json"); + pushField(fields, "Request body", block.request_body, "json"); + pushField(fields, "Continue on failure", block.continue_on_failure); + return fields; +} + +function getOutputValue(block: WorkflowRunBlock): unknown { + if ( + isTaskVariantBlock(block) && + block.status === Status.Completed && + hasExtractedInformation(block.output) + ) { + return block.output.extracted_information; + } + return block.output; +} + +function getSummaryFields(block: WorkflowRunBlock): Array { + const fields: Array = []; + pushField(fields, "Task ID", block.task_id); + pushField(fields, "Engine", block.engine); + pushField(fields, "Failure reason", block.failure_reason); + pushField(fields, "Executed branch", block.executed_branch_expression); + pushField(fields, "Executed branch result", block.executed_branch_result); + pushField(fields, "Executed next block", block.executed_branch_next_block); + return fields; +} + +function BlockInspector({ block }: { block: WorkflowRunBlock }) { + const inputFields = useMemo(() => getInputFields(block), [block]); + const summaryFields = useMemo(() => getSummaryFields(block), [block]); + const outputValue = getOutputValue(block); + const hasOutput = !isEmptyValue(outputValue); + const defaultTab = hasOutput ? "outputs" : "summary"; + const [activeTab, setActiveTab] = useState(defaultTab); + const triggerClassName = + "rounded px-2.5 py-1 text-xs font-medium text-slate-400 transition-colors hover:bg-white/5 hover:text-slate-200 data-[state=active]:bg-slate-elevation4 data-[state=active]:text-slate-50 data-[state=active]:shadow-sm"; + + useEffect(() => { + setActiveTab(defaultTab); + }, [block.workflow_run_block_id, defaultTab]); + + return ( +
+ + + + Summary + + + Inputs + + + Outputs + + + + + + + + + + {hasOutput ? ( + + ) : ( +
No block output.
+ )} +
+
+
+ ); +} + +export { BlockInspector, JsonExplorer }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/EmptyState.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/EmptyState.tsx new file mode 100644 index 000000000..8bb2508fa --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/EmptyState.tsx @@ -0,0 +1,14 @@ +function EmptyState() { + return ( +
+
+
No block selected
+
+ Click a row in the timeline to see its details here. +
+
+
+ ); +} + +export { EmptyState }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/formatValue.ts b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/formatValue.ts new file mode 100644 index 000000000..b8719c98c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/formatValue.ts @@ -0,0 +1,18 @@ +function stringifyTimelineValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export { stringifyTimelineValue }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/shared.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/shared.tsx new file mode 100644 index 000000000..0f478f724 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDetail/shared.tsx @@ -0,0 +1,404 @@ +import { useState } from "react"; +import { + CheckCircledIcon, + CrossCircledIcon, + ReloadIcon, +} from "@radix-ui/react-icons"; +import { Status, type ActionsApiResponse } from "@/api/types"; +import { CopyButton } from "@/components/CopyButton"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ActionCardCompact } from "@/routes/tasks/detail/ActionCardCompact"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { formatDuration, toDuration } from "@/routes/workflows/utils"; +import { workflowBlockTitle } from "@/routes/workflows/editor/nodes/types"; +import { WorkflowBlockIcon } from "@/routes/workflows/editor/nodes/WorkflowBlockIcon"; +import { cn } from "@/util/utils"; +import { + isAction, + type ObserverThought, + type WorkflowRunBlock, +} from "../../types/workflowRunTypes"; +import type { WorkflowRunOverviewActiveElement } from "../WorkflowRunOverview"; +import { ThoughtCard } from "../ThoughtCard"; +import { stringifyTimelineValue } from "./formatValue"; + +function TruncatedWithTooltip({ + full, + className, + side = "top", +}: { + full: string; + className?: string; + side?: "top" | "bottom" | "left" | "right"; +}) { + return ( + + + + {full} + + + {full} + + + + ); +} + +function StatusPill({ status }: { status: Status | null }) { + if (status === null) return null; + const isSuccess = status === Status.Completed; + const isFailure = + status === Status.Failed || + status === Status.Terminated || + status === Status.TimedOut || + status === Status.Canceled; + const isRunning = status === Status.Running; + + if (isSuccess) { + return ( + + + Completed + + ); + } + if (isFailure) { + return ( + + + {status} + + ); + } + if (isRunning) { + return ( + + + Running + + ); + } + return ( + + {status} + + ); +} + +function BlockDetailHeader({ + block, + iterationOverride, +}: { + block: WorkflowRunBlock; + iterationOverride?: number | null; +}) { + const duration = + block.duration !== null ? formatDuration(toDuration(block.duration)) : null; + const hasIterationOverride = + iterationOverride !== undefined && iterationOverride !== null; + const hasResolvedIterationOverride = + hasIterationOverride && + Array.isArray(block.loop_values) && + iterationOverride >= 0 && + iterationOverride < block.loop_values.length; + const iterationIndex = hasResolvedIterationOverride + ? iterationOverride + : hasIterationOverride + ? null + : block.current_index; + const iterationLabel = + iterationIndex !== null && iterationIndex !== undefined + ? `Iteration ${iterationIndex + 1}` + : null; + // When a loop block has an explicit iteration selection, source the chip + // value from loop_values[i] so it matches the iteration label. Without + // this, the chip displays block.current_value (the latest iteration) + // alongside a label for an older iteration — a contradictory pairing. + let valueToShow: unknown = null; + if (hasResolvedIterationOverride && Array.isArray(block.loop_values)) { + const resolvedIterationIndex = iterationOverride as number; + valueToShow = block.loop_values[resolvedIterationIndex]; + } else if (!hasIterationOverride) { + valueToShow = block.current_value; + } + const currentValueFull = + valueToShow !== null && valueToShow !== undefined + ? stringifyTimelineValue(valueToShow) + : null; + const currentValuePreview = + currentValueFull !== null + ? currentValueFull.replace(/\s+/g, " ").trim() + : null; + + return ( +
+
+ + + {workflowBlockTitle[block.block_type]} + + {block.label && ( + <> + · + + + )} + · + + {iterationLabel && ( + + {iterationLabel} + + )} + + {duration && ( + + {duration} + + )} + + +
+ {currentValuePreview && ( +
+ Iterated value: + + + + + {currentValuePreview} + + + + {currentValueFull} + + + +
+ )} + {block.description && ( +
+
{block.description}
+
+ )} +
+ ); +} + +function BlockDetailHeaderSkeleton() { + return ( +
+
+ + + · + + + + + +
+
+ ); +} + +function Section({ + title, + children, + className, +}: { + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} + +function BlockDetailFailure({ block }: { block: WorkflowRunBlock }) { + if (!block.failure_reason) return null; + return ( +
+
+ Failure +
+
+ {block.failure_reason} +
+
+ ); +} + +function CodeBlock({ + children, + className, + copyValue, +}: { + children: React.ReactNode; + className?: string; + copyValue?: string; +}) { + // When copyValue is provided, render the block with a hover-revealed copy + // affordance in the top-right corner. The wrapper has `group` so the + // CopyButton uses opacity transitions to fade in. + if (copyValue !== undefined) { + return ( +
+
+          {children}
+        
+
+ +
+
+ ); + } + return ( +
+      {children}
+    
+ ); +} + +function JsonView({ value }: { value: unknown }) { + const text = stringifyTimelineValue(value); + return {text}; +} + +function GoalText({ text }: { text: string | null | undefined }) { + if (!text) return null; + return ( +
+ {text} +
+ ); +} + +function BlockActionList({ + block, + activeItem, + onActionSelect, +}: { + block: WorkflowRunBlock; + activeItem: WorkflowRunOverviewActiveElement; + onActionSelect?: (payload: { + block: WorkflowRunBlock; + action: ActionsApiResponse; + }) => void; +}) { + const [expandedActionId, setExpandedActionId] = useState(null); + const actions = block.actions ?? []; + const actionsTopDown = [...actions].reverse(); + if (actions.length === 0) return null; + return ( +
+
+ {actionsTopDown.map((action, index) => { + const isActive = + isAction(activeItem) && activeItem.action_id === action.action_id; + return ( + { + setExpandedActionId((prev) => + prev === action.action_id ? null : action.action_id, + ); + }} + onSelect={() => { + onActionSelect?.({ block, action }); + }} + /> + ); + })} +
+
+ ); +} + +function BlockThoughtList({ + thoughts, + activeItem, + onSelect, +}: { + thoughts: Array; + activeItem: WorkflowRunOverviewActiveElement; + onSelect?: (thought: ObserverThought) => void; +}) { + if (thoughts.length === 0) return null; + return ( +
+
+ {thoughts.map((thought) => { + const isActive = + activeItem !== null && + activeItem !== "stream" && + typeof activeItem === "object" && + "thought_id" in activeItem && + activeItem.thought_id === thought.thought_id; + return ( + onSelect?.(thought)} + /> + ); + })} +
+
+ ); +} + +export { + BlockActionList, + BlockDetailFailure, + BlockDetailHeader, + BlockDetailHeaderSkeleton, + BlockThoughtList, + CodeBlock, + GoalText, + JsonView, + Section, + StatusPill, +}; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.test.ts b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.test.ts new file mode 100644 index 000000000..30b37a54f --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, test } from "vitest"; + +import { Status } from "@/api/types"; +import { + countActionsInTimeline, + type ObserverThought, + type WorkflowRunBlock, + type WorkflowRunTimelineBlockItem, + type WorkflowRunTimelineItem, +} from "../types/workflowRunTypes"; +import { + aggregateIterationStatus, + findActiveItem, + findBlockSurroundingThought, + findLastExecutedBlock, + findRunningBlock, + resolveScreenshotBlockId, +} from "./workflowTimelineUtils"; + +function buildBlock( + overrides: Partial = {}, +): WorkflowRunBlock { + return { + workflow_run_block_id: "wrb_default", + workflow_run_id: "wr_default", + parent_workflow_run_block_id: null, + block_type: "http_request", + label: null, + description: null, + title: null, + status: Status.Completed, + failure_reason: null, + output: null, + continue_on_failure: false, + task_id: null, + url: null, + navigation_goal: null, + navigation_payload: null, + data_extraction_goal: null, + data_schema: null, + terminate_criterion: null, + complete_criterion: null, + include_action_history_in_verification: null, + engine: null, + actions: null, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:00Z", + duration: null, + loop_values: null, + current_value: null, + current_index: null, + ...overrides, + }; +} + +function buildBlockItem( + block: WorkflowRunBlock, + children: Array = [], +): WorkflowRunTimelineBlockItem { + return { + type: "block", + block, + children, + thought: null, + created_at: block.created_at, + modified_at: block.modified_at, + }; +} + +function buildThoughtItem( + overrides: Partial = {}, +): WorkflowRunTimelineItem { + const thought = { + thought_id: "thought_default", + user_input: null, + observation: null, + thought: "Thinking", + answer: null, + created_at: "2026-01-01T00:00:02Z", + modified_at: "2026-01-01T00:00:02Z", + ...overrides, + }; + return { + type: "thought", + block: null, + children: [], + thought, + created_at: thought.created_at, + modified_at: thought.modified_at, + }; +} + +describe("findRunningBlock", () => { + test("returns null when no block is running", () => { + const timeline = [buildBlockItem(buildBlock({ status: Status.Completed }))]; + expect(findRunningBlock(timeline)).toBeNull(); + }); + + test("returns the deepest running block (leaf preferred over container)", () => { + // outer for_loop is running because its child is running, and so is the + // nested http_request leaf. We want the leaf, not the loop. + const leaf = buildBlock({ + workflow_run_block_id: "wrb_leaf", + block_type: "http_request", + status: Status.Running, + }); + const conditional = buildBlock({ + workflow_run_block_id: "wrb_cond", + block_type: "conditional", + status: Status.Running, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + status: Status.Running, + }); + + const timeline: Array = [ + buildBlockItem(loop, [ + buildBlockItem(conditional, [buildBlockItem(leaf)]), + ]), + ]; + + expect(findRunningBlock(timeline)?.workflow_run_block_id).toBe("wrb_leaf"); + }); +}); + +describe("findBlockSurroundingThought", () => { + test("falls back to an in-progress block whose modified_at lags the thought", () => { + const thought = buildThoughtItem({ + thought_id: "thought_live", + created_at: "2026-01-01T00:00:10Z", + modified_at: "2026-01-01T00:00:10Z", + }); + const block = buildBlock({ + workflow_run_block_id: "wrb_running", + status: Status.Running, + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:05Z", + }); + + expect( + findBlockSurroundingThought( + [buildBlockItem(block), thought], + "thought_live", + )?.workflow_run_block_id, + ).toBe("wrb_running"); + }); + + test("uses deepest matching block as the time-range fallback tie-breaker", () => { + const thought = buildThoughtItem({ + thought_id: "thought_nested", + created_at: "2026-01-01T00:00:05Z", + modified_at: "2026-01-01T00:00:05Z", + }); + const outer = buildBlock({ + workflow_run_block_id: "wrb_outer", + block_type: "for_loop", + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:10Z", + }); + const inner = buildBlock({ + workflow_run_block_id: "wrb_inner", + created_at: "2026-01-01T00:00:00Z", + modified_at: "2026-01-01T00:00:10Z", + }); + + expect( + findBlockSurroundingThought( + [buildBlockItem(outer, [buildBlockItem(inner)]), thought], + "thought_nested", + )?.workflow_run_block_id, + ).toBe("wrb_inner"); + }); +}); + +describe("countActionsInTimeline", () => { + test("counts actions on conditional blocks", () => { + const conditional = buildBlock({ + workflow_run_block_id: "wrb_cond", + block_type: "conditional", + actions: [ + { action_id: "act_extract" }, + ] as unknown as WorkflowRunBlock["actions"], + }); + + expect(countActionsInTimeline([buildBlockItem(conditional)])).toBe(1); + }); +}); + +describe("findLastExecutedBlock", () => { + test("returns null when there are no terminal leaf blocks", () => { + const timeline = [buildBlockItem(buildBlock({ status: Status.Running }))]; + expect(findLastExecutedBlock(timeline)).toBeNull(); + }); + + test("filters to leaves so an outer container doesn't shadow the actual last step", () => { + // Container's modified_at is later than its child's (containers close + // after their children). The helper should still surface the leaf. + const leaf = buildBlock({ + workflow_run_block_id: "wrb_leaf", + status: Status.Completed, + modified_at: "2026-01-01T00:00:01Z", + }); + const container = buildBlock({ + workflow_run_block_id: "wrb_container", + block_type: "for_loop", + status: Status.Completed, + // modified after the leaf finished + modified_at: "2026-01-01T00:00:05Z", + }); + + const timeline: Array = [ + buildBlockItem(container, [buildBlockItem(leaf)]), + ]; + + expect(findLastExecutedBlock(timeline)?.workflow_run_block_id).toBe( + "wrb_leaf", + ); + }); + + test("returns the most recently modified leaf when there are several", () => { + const earlier = buildBlock({ + workflow_run_block_id: "wrb_earlier", + status: Status.Completed, + modified_at: "2026-01-01T00:00:01Z", + }); + const later = buildBlock({ + workflow_run_block_id: "wrb_later", + status: Status.Completed, + modified_at: "2026-01-01T00:00:09Z", + }); + + const timeline: Array = [ + buildBlockItem(earlier), + buildBlockItem(later), + ]; + + expect(findLastExecutedBlock(timeline)?.workflow_run_block_id).toBe( + "wrb_later", + ); + }); + + test("ignores running blocks (only terminal states count)", () => { + const completed = buildBlock({ + workflow_run_block_id: "wrb_done", + status: Status.Completed, + modified_at: "2026-01-01T00:00:01Z", + }); + const running = buildBlock({ + workflow_run_block_id: "wrb_running", + status: Status.Running, + // running's modified_at is later, but we still want completed + modified_at: "2026-01-01T00:00:09Z", + }); + + const timeline: Array = [ + buildBlockItem(running), + buildBlockItem(completed), + ]; + + expect(findLastExecutedBlock(timeline)?.workflow_run_block_id).toBe( + "wrb_done", + ); + }); + + test("ignores leaves in non-terminal pre-execution states", () => { + const completed = buildBlock({ + workflow_run_block_id: "wrb_done", + status: Status.Completed, + modified_at: "2026-01-01T00:00:01Z", + }); + const queued = buildBlock({ + workflow_run_block_id: "wrb_queued", + status: Status.Queued, + // queued's modified_at is later, but it hasn't executed yet + modified_at: "2026-01-01T00:00:09Z", + }); + const created = buildBlock({ + workflow_run_block_id: "wrb_created", + status: Status.Created, + modified_at: "2026-01-01T00:00:08Z", + }); + const paused = buildBlock({ + workflow_run_block_id: "wrb_paused", + status: Status.Paused, + modified_at: "2026-01-01T00:00:07Z", + }); + + const timeline: Array = [ + buildBlockItem(queued), + buildBlockItem(created), + buildBlockItem(paused), + buildBlockItem(completed), + ]; + + expect(findLastExecutedBlock(timeline)?.workflow_run_block_id).toBe( + "wrb_done", + ); + }); +}); + +describe("findActiveItem default selection", () => { + test("returns 'stream' when no active param and the run is still in progress", () => { + const timeline = [buildBlockItem(buildBlock({ status: Status.Running }))]; + expect(findActiveItem(timeline, null, false)).toBe("stream"); + }); + + test("returns the last-executed leaf when finalized and no active param", () => { + const leaf = buildBlock({ + workflow_run_block_id: "wrb_leaf", + status: Status.Completed, + modified_at: "2026-01-01T00:00:01Z", + }); + const container = buildBlock({ + workflow_run_block_id: "wrb_container", + block_type: "for_loop", + status: Status.Completed, + modified_at: "2026-01-01T00:00:05Z", + }); + + const timeline: Array = [ + buildBlockItem(container, [buildBlockItem(leaf)]), + ]; + + const result = findActiveItem(timeline, null, true); + // result is the leaf block itself (no actions to prefer) + expect(result).not.toBeNull(); + if ( + result && + typeof result === "object" && + "workflow_run_block_id" in result + ) { + expect(result.workflow_run_block_id).toBe("wrb_leaf"); + } + }); + + test("explicit target id wins over default selection", () => { + const a = buildBlock({ + workflow_run_block_id: "wrb_a", + modified_at: "2026-01-01T00:00:01Z", + }); + const b = buildBlock({ + workflow_run_block_id: "wrb_b", + modified_at: "2026-01-01T00:00:09Z", + }); + + const timeline: Array = [ + buildBlockItem(a), + buildBlockItem(b), + ]; + + const result = findActiveItem(timeline, "wrb_a", true); + expect(result).not.toBeNull(); + if ( + result && + typeof result === "object" && + "workflow_run_block_id" in result + ) { + expect(result.workflow_run_block_id).toBe("wrb_a"); + } + }); +}); + +describe("aggregateIterationStatus", () => { + test("returns null for an empty input", () => { + expect(aggregateIterationStatus([])).toBeNull(); + }); + + test("returns Completed when every child is Completed", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed })), + buildBlockItem(buildBlock({ status: Status.Completed })), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Completed); + }); + + test("returns Completed when children mix Completed and Skipped", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed })), + buildBlockItem(buildBlock({ status: Status.Skipped })), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Completed); + }); + + test("returns Failed when any child is Failed/Terminated/TimedOut/Canceled", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed })), + buildBlockItem(buildBlock({ status: Status.Terminated })), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Failed); + }); + + test("returns Running when any child is Running and nothing failed", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed })), + buildBlockItem(buildBlock({ status: Status.Running })), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Running); + }); + + test("prefers Failed over Running when both are present", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Running })), + buildBlockItem(buildBlock({ status: Status.Failed })), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Failed); + }); + + test("returns null when every child is in a pre-execution state", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Created })), + buildBlockItem(buildBlock({ status: Status.Queued })), + buildBlockItem(buildBlock({ status: Status.Paused })), + ]; + expect(aggregateIterationStatus(items)).toBeNull(); + }); + + test("returns null when children mix Completed with pending — does not claim Completed prematurely", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed })), + buildBlockItem(buildBlock({ status: Status.Queued })), + ]; + expect(aggregateIterationStatus(items)).toBeNull(); + }); + + test("returns null when a child status is null", () => { + const items = [buildBlockItem(buildBlock({ status: null }))]; + expect(aggregateIterationStatus(items)).toBeNull(); + }); + + test("walks nested children", () => { + const items = [ + buildBlockItem(buildBlock({ status: Status.Completed }), [ + buildBlockItem(buildBlock({ status: Status.Failed })), + ]), + ]; + expect(aggregateIterationStatus(items)).toBe(Status.Failed); + }); +}); + +describe("resolveScreenshotBlockId", () => { + test("returns the block's own id when it isn't a container", () => { + const leaf = buildBlock({ + workflow_run_block_id: "wrb_leaf", + block_type: "http_request", + }); + expect(resolveScreenshotBlockId([buildBlockItem(leaf)], leaf)).toBe( + "wrb_leaf", + ); + }); + + test("falls back to the newest leaf when no iteration index is provided", () => { + // for_loop with two iterations; children are DESC-sorted by recency. + // iteration 1 (newest) appears first; iteration 0 last. + const newest = buildBlock({ + workflow_run_block_id: "wrb_iter1_leaf", + block_type: "http_request", + current_index: 1, + }); + const oldest = buildBlock({ + workflow_run_block_id: "wrb_iter0_leaf", + block_type: "http_request", + current_index: 0, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + }); + const timeline = [ + buildBlockItem(loop, [buildBlockItem(newest), buildBlockItem(oldest)]), + ]; + expect(resolveScreenshotBlockId(timeline, loop)).toBe("wrb_iter1_leaf"); + }); + + test("scopes the leaf walk to the requested iteration's children", () => { + const newest = buildBlock({ + workflow_run_block_id: "wrb_iter1_leaf", + block_type: "http_request", + current_index: 1, + }); + const oldest = buildBlock({ + workflow_run_block_id: "wrb_iter0_leaf", + block_type: "http_request", + current_index: 0, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + }); + const timeline = [ + buildBlockItem(loop, [buildBlockItem(newest), buildBlockItem(oldest)]), + ]; + // Asking for iteration 0 should NOT surface the iteration-1 leaf. + expect(resolveScreenshotBlockId(timeline, loop, 0)).toBe("wrb_iter0_leaf"); + expect(resolveScreenshotBlockId(timeline, loop, 1)).toBe("wrb_iter1_leaf"); + }); + + test("falls through to the newest leaf when the requested iteration has no children", () => { + // URL says iteration=99 but the loop only ran two iterations. + const newest = buildBlock({ + workflow_run_block_id: "wrb_iter1_leaf", + block_type: "http_request", + current_index: 1, + }); + const loop = buildBlock({ + workflow_run_block_id: "wrb_loop", + block_type: "for_loop", + }); + const timeline = [buildBlockItem(loop, [buildBlockItem(newest)])]; + expect(resolveScreenshotBlockId(timeline, loop, 99)).toBe("wrb_iter1_leaf"); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts index 7eea93b22..6044fb2ad 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/workflowTimelineUtils.ts @@ -1,6 +1,9 @@ +import { Status } from "@/api/types"; +import { statusIsFinalized } from "@/routes/tasks/types"; import { isBlockItem, isThoughtItem, + ObserverThought, WorkflowRunBlock, WorkflowRunTimelineItem, } from "../types/workflowRunTypes"; @@ -8,6 +11,12 @@ import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview"; const containerBlockTypes = new Set(["for_loop", "while_loop", "conditional"]); +function parseActiveIterationParam(value: string | null): number | null { + if (value === null || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + function findBlockSurroundingAction( timeline: Array, actionId: string, @@ -29,6 +38,77 @@ function findBlockSurroundingAction( } } +function findBlockSurroundingThought( + timeline: Array, + thoughtId: string, +): WorkflowRunBlock | undefined { + let thought: ObserverThought | null = null; + const stack = timeline.map((item) => ({ + item, + parentBlock: undefined as WorkflowRunBlock | undefined, + })); + + while (stack.length > 0) { + const { item, parentBlock } = stack.pop()!; + if (isThoughtItem(item) && item.thought.thought_id === thoughtId) { + if (parentBlock) return parentBlock; + thought = item.thought; + break; + } + const nextParent = isBlockItem(item) ? item.block : parentBlock; + for (const child of item.children) { + stack.push({ item: child, parentBlock: nextParent }); + } + } + + if (!thought) return undefined; + + const thoughtTime = new Date(thought.created_at).getTime(); + if (Number.isNaN(thoughtTime)) return undefined; + + let best: { + block: WorkflowRunBlock; + depth: number; + duration: number; + } | null = null; + const blockStack = [...timeline].reverse().map((item) => ({ + item, + depth: 0, + })); + while (blockStack.length > 0) { + const { item, depth } = blockStack.pop()!; + if (isBlockItem(item)) { + const start = new Date(item.block.created_at).getTime(); + const end = new Date(item.block.modified_at).getTime(); + const isFinalized = + item.block.status !== null && + statusIsFinalized({ status: item.block.status }); + const upperBound = isFinalized && !Number.isNaN(end) ? end : Infinity; + if ( + !Number.isNaN(start) && + thoughtTime >= start && + thoughtTime <= upperBound + ) { + const duration = Number.isFinite(upperBound) + ? upperBound - start + : Number.POSITIVE_INFINITY; + if ( + best === null || + depth > best.depth || + (depth === best.depth && duration < best.duration) + ) { + best = { block: item.block, depth, duration }; + } + } + } + for (const child of [...item.children].reverse()) { + blockStack.push({ item: child, depth: depth + 1 }); + } + } + + return best?.block; +} + function findActiveItem( timeline: Array, target: string | null, @@ -54,6 +134,15 @@ function findActiveItem( return finallyBlock.block; } } + // Prefer the deepest last-executed leaf — the actual final unit of work, + // not the outermost container that wraps it. + const lastLeaf = findLastExecutedBlock(timeline); + if (lastLeaf) { + if (lastLeaf.actions && lastLeaf.actions.length > 0) { + return lastLeaf.actions[0]!; + } + return lastLeaf; + } if (timeline?.length > 0) { const timelineItem = timeline![0]; if (isBlockItem(timelineItem)) { @@ -104,14 +193,15 @@ function findActiveItem( } /** - * For container blocks (for_loop, conditional) that don't have their own - * screenshots, find the first descendant leaf block whose artifacts can be shown. - * Timeline children are ordered most-recent-first (DESC), so the first leaf - * block we encounter is the most recent one. + * Container blocks have no screenshots; fall back to the most recent + * descendant leaf. Timeline children are DESC, so the first leaf wins. + * When a specific loop iteration is selected, scope the walk to that + * iteration's children so the screenshot tracks Panel B's iteration. */ function resolveScreenshotBlockId( timeline: Array, block: WorkflowRunBlock, + iterationIndex: number | null = null, ): string { if (!containerBlockTypes.has(block.block_type)) { return block.workflow_run_block_id; @@ -125,6 +215,20 @@ function resolveScreenshotBlockId( return block.workflow_run_block_id; } + if ( + iterationIndex !== null && + (block.block_type === "for_loop" || block.block_type === "while_loop") + ) { + const iterationChildren = timelineItem.children.filter( + (item) => + isBlockItem(item) && item.block.current_index === iterationIndex, + ); + const descendantInIteration = findFirstLeafBlockId(iterationChildren); + if (descendantInIteration) return descendantInIteration; + // Iteration's children aren't present (stale URL / not yet executed); + // fall through to the generic newest-leaf path. + } + const descendant = findFirstLeafBlockId(timelineItem.children); return descendant ?? block.workflow_run_block_id; } @@ -152,11 +256,13 @@ function findTimelineBlockItem( function findFirstLeafBlockId( items: Array, ): string | null { - for (const item of items) { + const stack = [...items].reverse(); + while (stack.length > 0) { + const item = stack.pop()!; if (isBlockItem(item)) { if (item.children.length > 0) { - const childResult = findFirstLeafBlockId(item.children); - if (childResult) return childResult; + stack.push(...[...item.children].reverse()); + continue; } return item.block.workflow_run_block_id; } @@ -164,4 +270,175 @@ function findFirstLeafBlockId( return null; } -export { findActiveItem, findBlockSurroundingAction, resolveScreenshotBlockId }; +/** + * Deepest running block. An outer container can still be Running while + * a descendant is doing the actual work — show the descendant. + */ +function findRunningBlock( + timeline: Array, +): WorkflowRunBlock | null { + let best: WorkflowRunBlock | null = null; + let bestDepth = -1; + const stack = [...timeline].reverse().map((item) => ({ item, depth: 0 })); + + while (stack.length > 0) { + const { item, depth } = stack.pop()!; + if (isBlockItem(item) && item.block.status === Status.Running) { + if (depth > bestDepth) { + best = item.block; + bestDepth = depth; + } + } + for (const child of [...item.children].reverse()) { + stack.push({ item: child, depth: depth + 1 }); + } + } + + return best; +} + +/** + * Most-recent leaf in a terminal state. Filter to leaves: containers + * always close last, so modified_at alone would pick the outer block. + */ +function findLastExecutedBlock( + timeline: Array, +): WorkflowRunBlock | null { + let latest: WorkflowRunBlock | null = null; + const stack = [...timeline]; + + while (stack.length > 0) { + const item = stack.pop()!; + if (isBlockItem(item)) { + const isLeaf = item.children.length === 0; + if ( + isLeaf && + item.block.status !== null && + (statusIsFinalized({ status: item.block.status }) || + item.block.status === Status.Skipped) + ) { + if ( + latest === null || + new Date(item.block.modified_at).getTime() > + new Date(latest.modified_at).getTime() + ) { + latest = item.block; + } + } + } + stack.push(...item.children); + } + + return latest; +} + +/** + * Thoughts belong to a block by subtree OR by `created_at` within the + * block's lifespan. Returned chronologically. + */ +function findThoughtsForBlock( + timeline: Array, + block: WorkflowRunBlock, +): Array { + const start = new Date(block.created_at).getTime(); + const end = new Date(block.modified_at).getTime(); + const thoughts: Array = []; + const seen = new Set(); + + function collectInBlockSubtree(items: Array) { + const stack = [...items]; + while (stack.length > 0) { + const item = stack.pop()!; + if (isThoughtItem(item)) { + if (!seen.has(item.thought.thought_id)) { + seen.add(item.thought.thought_id); + thoughts.push(item.thought); + } + } + stack.push(...item.children); + } + } + + const stack = [...timeline]; + while (stack.length > 0) { + const item = stack.pop()!; + if ( + isBlockItem(item) && + item.block.workflow_run_block_id === block.workflow_run_block_id + ) { + collectInBlockSubtree(item.children); + } + if (isThoughtItem(item)) { + const t = new Date(item.thought.created_at).getTime(); + if (t >= start && t <= end && !seen.has(item.thought.thought_id)) { + seen.add(item.thought.thought_id); + thoughts.push(item.thought); + } + } + stack.push(...item.children); + } + + thoughts.sort( + (a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ); + return thoughts; +} + +/** + * Aggregate the status of an iteration's children. Returns Completed only + * when every child is in a truly terminal state (Completed or Skipped). + * Pending children (Created/Queued/Paused/null) surface as null so the + * iteration row renders a neutral dot, not a misleading green check. + */ +function aggregateIterationStatus( + items: Array, +): Status | null { + let hasRunning = false; + let hasFailure = false; + let hasNonTerminal = false; + let hasAny = false; + const stack = [...items]; + while (stack.length > 0) { + const item = stack.pop()!; + if (isBlockItem(item)) { + hasAny = true; + const s = item.block.status; + if (s === Status.Running) { + hasRunning = true; + } else if ( + s === Status.Failed || + s === Status.Terminated || + s === Status.TimedOut || + s === Status.Canceled + ) { + hasFailure = true; + } else if ( + s === null || + s === Status.Created || + s === Status.Queued || + s === Status.Paused + ) { + hasNonTerminal = true; + } + } + stack.push(...item.children); + } + if (!hasAny) return null; + if (hasFailure) return Status.Failed; + if (hasRunning) return Status.Running; + if (hasNonTerminal) return null; + return Status.Completed; +} + +export { + aggregateIterationStatus, + findActiveItem, + findBlockSurroundingAction, + findBlockSurroundingThought, + findLastExecutedBlock, + findRunningBlock, + findThoughtsForBlock, + parseActiveIterationParam, + resolveScreenshotBlockId, +}; diff --git a/skyvern/forge/agent_functions.py b/skyvern/forge/agent_functions.py index 26e510254..33ae079bd 100644 --- a/skyvern/forge/agent_functions.py +++ b/skyvern/forge/agent_functions.py @@ -494,6 +494,14 @@ class AgentFunction: ) -> bool: return True + async def get_analytics_warmable_organization_ids(self, statement_timeout_seconds: int = 10) -> list[str]: + """Return org IDs whose analytics summary cache should be kept warm. + + OSS returns all org IDs. Cloud overrides with enterprise pricing filter. + """ + orgs = await app.DATABASE.organizations.get_all_organizations() + return [org.organization_id for org in orgs] + async def record_workflow_run_metadata( self, *, diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index dc8b39de9..9356dc75c 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -1205,6 +1205,9 @@ class TaskRunModel(Base): memory_mb = Column(Integer, nullable=True) duration_ms = Column(BigInteger, nullable=True) compute_cost = Column(Numeric, nullable=True) + llm_cost = Column(Numeric, nullable=True) + proxy_cost = Column(Numeric, nullable=True) + captcha_cost = Column(Numeric, nullable=True) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) diff --git a/skyvern/forge/sdk/db/repositories/tasks.py b/skyvern/forge/sdk/db/repositories/tasks.py index c2341f219..9c32ba7a5 100644 --- a/skyvern/forge/sdk/db/repositories/tasks.py +++ b/skyvern/forge/sdk/db/repositories/tasks.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta, timezone +from decimal import Decimal from typing import Any, Sequence import structlog @@ -1017,7 +1018,10 @@ class TasksRepository(BaseRepository): vcpu_millicores: int | None = None, memory_mb: int | None = None, duration_ms: int | None = None, - compute_cost: float | None = None, + compute_cost: Decimal | None = None, + llm_cost: Decimal | None = None, + proxy_cost: Decimal | None = None, + captcha_cost: Decimal | None = None, ) -> None: """Update compute cost metrics for a job run.""" async with self.Session() as session: @@ -1044,6 +1048,12 @@ class TasksRepository(BaseRepository): task_run.duration_ms = duration_ms if compute_cost is not None: task_run.compute_cost = compute_cost + if llm_cost is not None: + task_run.llm_cost = llm_cost + if proxy_cost is not None: + task_run.proxy_cost = proxy_cost + if captcha_cost is not None: + task_run.captcha_cost = captcha_cost await session.commit() @db_operation("cache_task_run") diff --git a/skyvern/forge/sdk/schemas/runs.py b/skyvern/forge/sdk/schemas/runs.py index 5e284cb99..5fdef11ac 100644 --- a/skyvern/forge/sdk/schemas/runs.py +++ b/skyvern/forge/sdk/schemas/runs.py @@ -32,5 +32,8 @@ class Run(BaseModel): vcpu_millicores: int | None = None duration_ms: int | None = None compute_cost: float | None = None + llm_cost: float | None = None + proxy_cost: float | None = None + captcha_cost: float | None = None created_at: datetime modified_at: datetime diff --git a/skyvern/services/task_v2_service.py b/skyvern/services/task_v2_service.py index 209a80c27..83774b324 100644 --- a/skyvern/services/task_v2_service.py +++ b/skyvern/services/task_v2_service.py @@ -1062,7 +1062,7 @@ async def run_task_v2_helper( ) break if block_result.success is True: - completion_screenshots = [] + completion_screenshots: list[bytes] = [] completion_scraped_page: ScrapedPage | None = None try: browser_state = await app.BROWSER_MANAGER.get_or_create_for_workflow_run( diff --git a/tests/unit/workflow/test_workflow_run_timeline_tree.py b/tests/unit/workflow/test_workflow_run_timeline_tree.py index 0b8ad33fa..9c8631aca 100644 --- a/tests/unit/workflow/test_workflow_run_timeline_tree.py +++ b/tests/unit/workflow/test_workflow_run_timeline_tree.py @@ -11,6 +11,7 @@ from skyvern.forge import app from skyvern.forge.sdk.schemas.workflow_runs import WorkflowRunBlock from skyvern.forge.sdk.workflow.service import WorkflowService from skyvern.schemas.workflows import BlockType +from skyvern.webeye.actions.actions import ExtractAction def _block( @@ -19,6 +20,7 @@ def _block( parent_id: str | None = None, created_at: datetime, block_type: BlockType = BlockType.TASK, + task_id: str | None = None, ) -> WorkflowRunBlock: return WorkflowRunBlock( workflow_run_block_id=block_id, @@ -26,6 +28,7 @@ def _block( organization_id="o_test", parent_workflow_run_block_id=parent_id, block_type=block_type, + task_id=task_id, created_at=created_at, modified_at=created_at, ) @@ -103,6 +106,28 @@ async def test_timeline_preserves_deep_nesting(mock_db: AsyncMock) -> None: assert child_ids == {"wrb_a", "wrb_b"} +@pytest.mark.asyncio +async def test_timeline_attaches_actions_to_conditional_blocks(mock_db: AsyncMock) -> None: + """Conditional prompt evaluation actions should appear on the conditional row.""" + base = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc) + conditional = _block( + "wrb_cond", + created_at=base, + block_type=BlockType.CONDITIONAL, + task_id="tsk_cond_eval", + ) + action = ExtractAction(action_id="act_cond_extract", task_id="tsk_cond_eval") + mock_db.return_value = [conditional] + app.DATABASE.tasks.get_tasks_actions.return_value = [action] + + service = WorkflowService() + timeline = await service.get_workflow_run_timeline(workflow_run_id="wr_test", organization_id="o_test") + + assert len(timeline) == 1 + assert timeline[0].block is not None + assert timeline[0].block.actions == [action] + + @pytest.mark.asyncio async def test_timeline_empty_run_returns_empty_list(mock_db: AsyncMock) -> None: """No blocks → empty timeline."""