From 3aaffb667ddb24d4fb4fa7af4a77ef17914d9ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Celal=20Zamano=C4=9Flu?= Date: Thu, 9 Jul 2026 18:57:07 +0300 Subject: [PATCH] feat(SKY-11339): progressive action reveal for copilot verify cards (#7232) --- .../copilot/NarrativeView.reveal.test.tsx | 168 +++++++ .../workflows/copilot/NarrativeView.tsx | 106 ++++- .../WorkflowCopilotChat.blockActions.test.tsx | 426 ++++++++++++++++++ .../workflows/copilot/WorkflowCopilotChat.tsx | 138 ++++++ .../workflows/copilot/actionReveal.test.ts | 65 +++ .../routes/workflows/copilot/actionReveal.ts | 45 ++ .../narrativeState.blockActions.test.ts | 274 +++++++++++ .../workflows/copilot/narrativeState.ts | 83 +++- .../workflows/workflowRun/useShimmerText.ts | 2 +- skyvern-frontend/src/util/featureFlags.ts | 4 + skyvern-frontend/tailwind.config.js | 10 + 11 files changed, 1312 insertions(+), 9 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/copilot/NarrativeView.reveal.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.blockActions.test.tsx create mode 100644 skyvern-frontend/src/routes/workflows/copilot/actionReveal.test.ts create mode 100644 skyvern-frontend/src/routes/workflows/copilot/actionReveal.ts create mode 100644 skyvern-frontend/src/routes/workflows/copilot/narrativeState.blockActions.test.ts diff --git a/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.reveal.test.tsx b/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.reveal.test.tsx new file mode 100644 index 000000000..59802f935 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.reveal.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { NarrativeView } from "./NarrativeView"; +import { + BlockState, + EMPTY_NARRATIVE, + RecordedActionSummary, + TurnNarrativeState, +} from "./narrativeState"; + +const NOW = new Date("2026-06-10T00:00:00Z").getTime(); + +const action = ( + actionId: string, + overrides: Partial = {}, +): RecordedActionSummary => ({ + actionId, + label: `Action ${actionId}`, + summary: null, + durationMs: 200, + failed: false, + ...overrides, +}); + +const verifyingBlockWithActions = ( + actions: RecordedActionSummary[], + recordedActionsAt: number, +): BlockState => ({ + workflowRunBlockId: "wrb_1", + label: "block_1", + blockType: "code", + state: "completed", + outcome: "evaluating", + lastSeenIteration: 1, + activity: [], + startedAt: "2026-06-10T00:00:00Z", + endedAt: "2026-06-10T00:00:05Z", + recordedActions: actions, + recordedActionsAt, +}); + +const inFlightTurnWithBlock = (block: BlockState): TurnNarrativeState => ({ + ...EMPTY_NARRATIVE, + turnId: "turn-1", + turnIndex: 0, + mode: "build", + blocks: [block], + terminal: null, + startedAt: "2026-06-10T00:00:00Z", +}); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("NarrativeView — recorded action reveal", () => { + it("reveals recorded actions one by one as time advances (regression pin)", () => { + const actions = [action("a1"), action("a2")]; + render( + , + ); + + // Only the first action has started revealing; the second hasn't + // appeared at all yet — old code renders neither, ever. + expect(screen.getByText("Action a1")).toBeTruthy(); + expect(screen.queryByText("Action a2")).toBeNull(); + expect(document.querySelectorAll(".animate-spin").length).toBe(1); + + act(() => { + vi.advanceTimersByTime(250); + }); + expect(screen.getByText("Action a2")).toBeTruthy(); + expect(document.querySelectorAll(".animate-spin").length).toBe(1); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(document.querySelectorAll(".animate-spin").length).toBe(0); + }); + + it("shows everything immediately with no in-progress row when recordedActionsAt is far in the past", () => { + const actions = [action("a1"), action("a2"), action("a3")]; + render( + , + ); + + expect(screen.getByText("Action a1")).toBeTruthy(); + expect(screen.getByText("Action a2")).toBeTruthy(); + expect(screen.getByText("Action a3")).toBeTruthy(); + expect(document.querySelectorAll(".animate-spin").length).toBe(0); + }); + + it("survives a real unmount/remount (rollup collapse -> expand) without restarting the schedule", () => { + const actions = [action("a1"), action("a2")]; + const failedBlock: BlockState = { + ...verifyingBlockWithActions(actions, NOW), + state: "failed", + outcome: undefined, + }; + const terminalTurn: TurnNarrativeState = { + ...inFlightTurnWithBlock(failedBlock), + terminal: "response", + terminalMessage: "Halted.", + endedAt: "2026-06-10T00:00:10Z", + }; + + render(); + // A terminal turn defaults to the rolled-up summary card — the block + // row (and its actions) are not mounted at all yet. + expect(screen.queryByText("Action a1")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(350); + }); + + // Expand into the detail view — FBlockRun mounts for the first time here. + fireEvent.click(screen.getByRole("button", { name: /Run halted/ })); + + // 350ms had already elapsed before this fresh mount: one action already + // done, the second mid-reveal — not restarted from zero. + expect(screen.getByText("Action a1")).toBeTruthy(); + expect(screen.getByText("Action a2")).toBeTruthy(); + expect(document.querySelectorAll(".animate-spin").length).toBe(1); + }); + + it("renders no recorded-action rows when the block has none (byte-identical to today)", () => { + const block: BlockState = { + workflowRunBlockId: "wrb_1", + label: "block_1", + blockType: "navigation", + state: "running", + lastSeenIteration: 1, + activity: [], + startedAt: "2026-06-10T00:00:00Z", + endedAt: null, + }; + render(); + + expect(screen.getByText("Working…")).toBeTruthy(); + expect( + document.querySelectorAll(".animate-copilot-row-flash-success").length, + ).toBe(0); + expect( + document.querySelectorAll(".animate-copilot-row-flash-error").length, + ).toBe(0); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.tsx b/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.tsx index 79c605242..bca738c8b 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.tsx +++ b/skyvern-frontend/src/routes/workflows/copilot/NarrativeView.tsx @@ -1,8 +1,10 @@ import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { buildRevealOffsets, revealedCountAt } from "./actionReveal"; import { ActivityEntry, BlockState, + RecordedActionSummary, TurnNarrativeState, TurnSummary, computeTurnSummary, @@ -13,6 +15,11 @@ import { parseUtcIsoMs, toolActivityDisplayLabel, } from "./narrativeState"; +import { useShimmerText } from "../workflowRun/useShimmerText"; + +// Row flashes green/red for 600ms once revealed — must match the tailwind +// copilot-row-flash-* animation duration. +const FLASH_WINDOW_MS = 600; interface BlockPalette { fg: string; @@ -188,13 +195,57 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) { ); } -function useSecondTick(active: boolean): void { +function useTick(active: boolean, intervalMs = 1000): void { const [, setTick] = useState(0); useEffect(() => { if (!active) return; - const id = setInterval(() => setTick((t) => t + 1), 1000); + const id = setInterval(() => setTick((t) => t + 1), intervalMs); return () => clearInterval(id); - }, [active]); + }, [active, intervalMs]); +} + +function FRecordedActionRow({ + action, + revealing, + flash, +}: { + action: RecordedActionSummary; + revealing: boolean; + flash: boolean; +}) { + const shimmerRef = useShimmerText(revealing); + if (revealing) { + return ( + } glyphClass="text-blue-300"> + + {action.label} + + {action.summary ? ( + · {action.summary} + ) : null} + + ); + } + const flashClass = flash + ? action.failed + ? "animate-copilot-row-flash-error" + : "animate-copilot-row-flash-success" + : ""; + return ( + + + {action.label} + + {action.summary ? ( + · {action.summary} + ) : null} + + ); } interface FBlockRunProps { @@ -247,11 +298,41 @@ function FBlockRun({ block, turnEnded, onSelect }: FBlockRunProps) { ? "bg-rose-500/15" : "bg-slate-elevation3"; + const recordedActions = block.recordedActions; + const hasActions = + recordedActions !== undefined && recordedActions.length > 0; + const durations = useMemo( + () => (recordedActions ?? []).map((a) => a.durationMs), + [recordedActions], + ); + const offsets = useMemo(() => buildRevealOffsets(durations), [durations]); + const totalMs = offsets.length > 0 ? offsets[offsets.length - 1]! : 0; + // Time-derived, not timer-chained: recomputed from wall-clock time on + // every render/tick so collapse, remount, and StrictMode double-invoke + // can never restart or duplicate the reveal. + const elapsedReveal = hasActions + ? Date.now() - (block.recordedActionsAt ?? 0) + : 0; + const revealedCount = hasActions + ? revealedCountAt(offsets, elapsedReveal) + : 0; + const replayingAction = + hasActions && elapsedReveal >= 0 && revealedCount < recordedActions!.length; + const visibleActionCount = !hasActions + ? 0 + : elapsedReveal < 0 + ? 0 + : Math.min( + revealedCount + (replayingAction ? 1 : 0), + recordedActions!.length, + ); + const [userOpen, setUserOpen] = useState(null); - const defaultOpen = isRunning || isFail; + const defaultOpen = isRunning || isFail || (hasActions && !turnEnded); const open = userOpen === null ? defaultOpen : userOpen; const toggleable = isOk || isOutcomeNotShown || isVerifying || isRanNeutral; - useSecondTick(isRunning); + useTick(isRunning); + useTick(hasActions && (replayingAction || elapsedReveal < totalMs), 150); const elapsed = formatElapsed(block.startedAt, block.endedAt); const live = isRunning ? liveElapsed(block.startedAt) : null; const statusText = isOk @@ -354,6 +435,21 @@ function FBlockRun({ block, turnEnded, onSelect }: FBlockRunProps) { {block.activity.map((entry) => ( ))} + {hasActions + ? recordedActions! + .slice(0, visibleActionCount) + .map((action, i) => ( + + )) + : null} {isFail ? (
diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.blockActions.test.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.blockActions.test.tsx new file mode 100644 index 000000000..0c7d08f6e --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.blockActions.test.tsx @@ -0,0 +1,426 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + FeatureFlagContext, + FeatureFlagValueContext, +} from "@/hooks/useFeatureFlag"; + +type StreamBody = { message: string; workflow_run_id?: string | null }; +type StreamCall = { + body: StreamBody; + onMessage: (payload: unknown) => boolean; + resolve: () => void; + reject: (error: unknown) => void; +}; + +const { mockCopilotUxV1Enabled } = vi.hoisted(() => ({ + mockCopilotUxV1Enabled: vi.fn(), +})); + +vi.mock("posthog-js/react", () => ({ + useFeatureFlagEnabled: () => mockCopilotUxV1Enabled(), +})); + +const { + streamCalls, + postStreaming, + cancelPost, + historyResponse, + routeParams, + timelineGet, +} = vi.hoisted(() => { + const calls: StreamCall[] = []; + const post = vi.fn().mockResolvedValue({}); + const streaming = vi.fn( + ( + _path: string, + body: StreamBody, + onMessage: (payload: unknown) => boolean, + ) => + new Promise((resolve, reject) => { + calls.push({ body, onMessage, resolve, reject }); + }), + ); + const history = { + data: { + workflow_copilot_chat_id: null as string | null, + chat_history: [] as unknown[], + proposed_workflow: null as Record | null, + auto_accept: false, + }, + }; + const params = { + current: { + workflowPermanentId: "wpid_1", + workflowRunId: undefined as string | undefined, + }, + }; + const timeline = vi.fn().mockResolvedValue({ data: [] }); + return { + streamCalls: calls, + postStreaming: streaming, + cancelPost: post, + historyResponse: history, + routeParams: params, + timelineGet: timeline, + }; +}); + +vi.mock("@/api/sse", () => ({ + getSseClient: vi.fn().mockResolvedValue({ postStreaming }), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: vi.fn().mockResolvedValue({ + get: vi.fn().mockImplementation((url: string) => { + if (url.includes("/timeline")) return timelineGet(url); + return Promise.resolve(historyResponse); + }), + post: cancelPost, + }), +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() })); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useParams: () => routeParams.current, + }; +}); + +const saveData = { + title: "Test WF", + workflow: { + workflow_id: "wf_1", + workflow_permanent_id: "wpid_1", + description: "", + totp_verification_url: null, + is_saved_task: false, + status: "published", + }, + settings: { + proxyLocation: null, + webhookCallbackUrl: null, + persistBrowserSession: false, + browserProfileId: null, + browserProfileKey: null, + model: null, + maxScreenshotScrolls: null, + extraHttpHeaders: null, + runWith: "agent", + scriptCacheKey: "", + aiFallback: true, + codeVersion: 2, + runSequentially: false, + sequentialKey: null, + }, + parameters: [], + blocks: [], + workflowDefinitionVersion: 1, +}; + +vi.mock("@/store/WorkflowHasChangesStore", () => ({ + useWorkflowHasChangesStore: () => ({ getSaveData: () => saveData }), +})); + +import { WorkflowCopilotChat } from "./WorkflowCopilotChat"; + +const BOOLEAN_FLAGS: Record = { + ENABLE_WORKFLOW_COPILOT_V2: true, + WORKFLOW_COPILOT_CODE_BLOCK_MODE: false, + CODE_BLOCK_ACCESS: false, +}; + +function chatUi() { + return ( + BOOLEAN_FLAGS[name]}> + undefined}> + + + + ); +} + +async function renderChat() { + const view = render(chatUi()); + await waitFor(() => + expect(screen.getByPlaceholderText(/Message Skyvern Copilot/)).toBeTruthy(), + ); + return view; +} + +async function submit(value: string) { + fireEvent.change(screen.getByRole("textbox"), { target: { value } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await waitFor(() => expect(postStreaming).toHaveBeenCalledTimes(1)); +} + +const runOutcomeFrame = (overrides: Partial> = {}) => ({ + type: "run_outcome", + workflow_run_id: "wr_1", + workflow_run_block_ids: ["wrb_1"], + block_labels: ["block_1"], + verdict: "evaluating", + iteration: 0, + timestamp: "2026-06-10T00:00:00Z", + ...overrides, +}); + +beforeEach(() => { + HTMLElement.prototype.scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollTo = vi.fn(); + mockCopilotUxV1Enabled.mockReset(); + mockCopilotUxV1Enabled.mockReturnValue(true); + streamCalls.length = 0; + postStreaming.mockClear(); + cancelPost.mockClear(); + timelineGet.mockClear(); + timelineGet.mockResolvedValue({ data: [] }); + historyResponse.data = { + workflow_copilot_chat_id: null, + chat_history: [], + proposed_workflow: null, + auto_accept: false, + }; + routeParams.current = { + workflowPermanentId: "wpid_1", + workflowRunId: undefined, + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("WorkflowCopilotChat — recorded-action fetch wiring", () => { + it("fetches the run timeline exactly once when a run_outcome frame arrives", async () => { + await renderChat(); + await submit("build a workflow"); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "" } }); + streamCalls[0]!.onMessage(runOutcomeFrame()); + + await waitFor(() => expect(timelineGet).toHaveBeenCalledTimes(1)); + expect(timelineGet.mock.calls[0]![0]).toBe( + "/workflows/wpid_1/runs/wr_1/timeline", + ); + }); + + it("does not re-fetch for a second run_outcome frame carrying the same run id", async () => { + await renderChat(); + await submit("build a workflow"); + + streamCalls[0]!.onMessage(runOutcomeFrame({ verdict: "evaluating" })); + await waitFor(() => expect(timelineGet).toHaveBeenCalledTimes(1)); + + streamCalls[0]!.onMessage(runOutcomeFrame({ verdict: "demonstrated" })); + // Give any accidental second fetch a chance to fire before asserting. + await waitFor(() => expect(timelineGet).toHaveBeenCalledTimes(1)); + }); + + it("never fetches when the run_outcome frame carries an empty workflow_run_id", async () => { + await renderChat(); + await submit("build a workflow"); + + streamCalls[0]!.onMessage(runOutcomeFrame({ workflow_run_id: "" })); + + expect(timelineGet).not.toHaveBeenCalled(); + }); + + it("never fetches the run timeline when copilot_ux_v1 is off", async () => { + mockCopilotUxV1Enabled.mockReturnValue(false); + await renderChat(); + await submit("build a workflow"); + + streamCalls[0]!.onMessage(runOutcomeFrame()); + + expect(timelineGet).not.toHaveBeenCalled(); + }); + + it("renders the recorded actions once the timeline fetch resolves", async () => { + timelineGet.mockResolvedValue({ + data: [ + { + type: "block", + block: { + workflow_run_block_id: "wrb_1", + actions: [ + { + action_id: "a1", + action_type: "wobble_gizmo", + status: "completed", + task_id: null, + step_id: null, + step_order: null, + action_order: 0, + confidence_float: null, + description: null, + reasoning: "Wobbled the gizmo into place", + intention: null, + response: null, + created_by: null, + text: null, + output: { duration_ms: 220 }, + }, + ], + }, + children: [], + thought: null, + created_at: "2026-06-10T00:00:00Z", + modified_at: "2026-06-10T00:00:00Z", + }, + ], + }); + + await renderChat(); + await submit("build a workflow"); + + streamCalls[0]!.onMessage({ + type: "turn_start", + turn_id: "turn-1", + turn_index: 0, + mode: "build", + timestamp: "2026-06-10T00:00:00Z", + }); + streamCalls[0]!.onMessage({ + type: "block_progress", + workflow_run_block_id: "wrb_1", + block_label: "block_1", + block_type: "code", + status: "running", + iteration: 0, + timestamp: "2026-06-10T00:00:00Z", + }); + streamCalls[0]!.onMessage({ + type: "block_progress", + workflow_run_block_id: "wrb_1", + block_label: "block_1", + block_type: "code", + status: "completed", + iteration: 0, + timestamp: "2026-06-10T00:00:05Z", + }); + streamCalls[0]!.onMessage(runOutcomeFrame()); + + await waitFor(() => expect(timelineGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText("Wobble Gizmo")).toBeTruthy()); + }); + + it("patches an already-frozen AI message when the timeline fetch resolves after the terminal response", async () => { + let resolveTimeline!: (value: { data: unknown[] }) => void; + timelineGet.mockImplementation( + () => + new Promise((resolve) => { + resolveTimeline = resolve; + }), + ); + + await renderChat(); + await submit("build a workflow"); + + streamCalls[0]!.onMessage({ + type: "turn_start", + turn_id: "turn-1", + turn_index: 0, + mode: "build", + timestamp: "2026-06-10T00:00:00Z", + }); + streamCalls[0]!.onMessage({ + type: "block_progress", + workflow_run_block_id: "wrb_1", + block_label: "block_1", + block_type: "code", + status: "running", + iteration: 0, + timestamp: "2026-06-10T00:00:00Z", + }); + streamCalls[0]!.onMessage({ + type: "block_progress", + workflow_run_block_id: "wrb_1", + block_label: "block_1", + block_type: "code", + status: "completed", + iteration: 0, + timestamp: "2026-06-10T00:00:05Z", + }); + streamCalls[0]!.onMessage(runOutcomeFrame()); + await waitFor(() => expect(timelineGet).toHaveBeenCalledTimes(1)); + + // Terminal response freezes the narrative BEFORE the timeline fetch + // (started above) resolves. + streamCalls[0]!.onMessage({ + type: "response", + workflow_copilot_chat_id: "chat_1", + message: "Done", + response_time: "2026-06-10T00:00:06Z", + proposal_disposition: "no_proposal", + turn_id: "turn-1", + narrative_payload: null, + }); + // The bottom live bubble and the newly-frozen message both briefly carry + // role="status"; wait for the live one to unmount (terminal narrative) + // rather than grabbing whichever settles first. + await waitFor(() => { + expect(screen.getAllByRole("status")).toHaveLength(1); + }); + + resolveTimeline({ + data: [ + { + type: "block", + block: { + workflow_run_block_id: "wrb_1", + actions: [ + { + action_id: "a1", + action_type: "wobble_gizmo", + status: "completed", + task_id: null, + step_id: null, + step_order: null, + action_order: 0, + confidence_float: null, + description: null, + reasoning: "Wobbled the gizmo into place", + intention: null, + response: null, + created_by: null, + text: null, + output: { duration_ms: 220 }, + }, + ], + }, + children: [], + thought: null, + created_at: "2026-06-10T00:00:00Z", + modified_at: "2026-06-10T00:00:00Z", + }, + ], + }); + + // A settled turn's card defaults to its rolled-up summary; expand it, + // then expand the block row, to reach the replay the fetch just patched + // in — this is the reviewer's "does the verify card ever receive it". + const statusRegion = await waitFor(() => screen.getByRole("status")); + fireEvent.click( + within(statusRegion).getByRole("button", { expanded: false }), + ); + fireEvent.click(within(statusRegion).getByText("block_1")); + + await waitFor(() => expect(screen.getByText("Wobble Gizmo")).toBeTruthy()); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx index 64508d555..527adb901 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx +++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx @@ -8,6 +8,7 @@ import { memo, } from "react"; import { getClient } from "@/api/AxiosClient"; +import { ActionsApiResponse, getReadableActionType } from "@/api/types"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useParams } from "react-router-dom"; import { @@ -24,6 +25,10 @@ import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore"; import { usePasteSkillHintStore } from "@/store/usePasteSkillHintStore"; import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes"; import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; +import { + isBlockItem, + WorkflowRunTimelineItem, +} from "@/routes/workflows/types/workflowRunTypes"; import { toast } from "@/components/ui/use-toast"; import { getSseClient } from "@/api/sse"; import { @@ -61,8 +66,10 @@ import { NarrativeView } from "./NarrativeView"; import { DiffCard, shouldShowDiffCard } from "./cards/DiffCard"; import { FixCard, shouldShowFixCard } from "./cards/FixCard"; import { + CopilotBlockActionsEvent, EMPTY_NARRATIVE, NarrativeEvent, + RecordedActionSummary, TurnNarrativeState, applyNarrativeEvent, hydrateHistoryNarrative, @@ -72,6 +79,8 @@ import { computeFollowSignature, useStickToBottom } from "./useStickToBottom"; import { useSpeechToTextField } from "@/hooks/useSpeechToTextField"; import { SpeechInputButton } from "@/components/SpeechInputButton"; import { useFeatureFlag, useFeatureFlagValue } from "@/hooks/useFeatureFlag"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { COPILOT_UX_V1_FLAG } from "@/util/featureFlags"; import { DropdownMenu, DropdownMenuTrigger, @@ -84,6 +93,60 @@ import { cn } from "@/util/utils"; // handful of turns; this ceiling guards a runaway long-running chat. const MAX_TURN_SNAPSHOTS = 20; +function normalizeInline(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.replace(/\s+/g, " ").trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function recordedActionDurationMs(action: ActionsApiResponse): number | null { + const output = action.output; + if (!output || typeof output !== "object" || Array.isArray(output)) { + return null; + } + const durationMs = (output as Record).duration_ms; + return typeof durationMs === "number" ? durationMs : null; +} + +function toRecordedActionSummary( + action: ActionsApiResponse, +): RecordedActionSummary { + return { + actionId: action.action_id, + label: getReadableActionType(action.action_type), + summary: + normalizeInline(action.reasoning) ?? + normalizeInline(action.text) ?? + normalizeInline(action.response) ?? + normalizeInline(action.description), + durationMs: recordedActionDurationMs(action), + failed: action.status === "failed", + }; +} + +// Timeline items nest branch/loop children; walk the whole tree so a +// conditional or loop body's blocks are not missed. +function collectTimelineBlockActions( + items: ReadonlyArray, +): Array<{ workflowRunBlockId: string; actions: ActionsApiResponse[] }> { + const out: Array<{ + workflowRunBlockId: string; + actions: ActionsApiResponse[]; + }> = []; + for (const item of items) { + if (isBlockItem(item)) { + out.push({ + workflowRunBlockId: item.block.workflow_run_block_id, + actions: item.block.actions ?? [], + }); + } + if (item.children.length > 0) { + out.push(...collectTimelineBlockActions(item.children)); + } + } + return out; +} + type ComposerDefaultVariant = | "build" | "build_code" @@ -448,6 +511,8 @@ export function WorkflowCopilotChat({ const copilotV2Flag = useFeatureFlag("ENABLE_WORKFLOW_COPILOT_V2"); const codeBlockModeFlag = useFeatureFlag("WORKFLOW_COPILOT_CODE_BLOCK_MODE"); const codeBlockAccessFlag = useFeatureFlag("CODE_BLOCK_ACCESS"); + // Client-side PostHog eval (not the backend-served flags above). + const copilotUxV1Enabled = useFeatureFlagEnabled(COPILOT_UX_V1_FLAG) ?? false; const copilotV2Enabled = copilotV2Flag === true; const codeBlockModeEnabled = codeBlockModeFlag === true && codeBlockAccessFlag === true; @@ -559,6 +624,10 @@ export function WorkflowCopilotChat({ // Most recent turn_id observed via turn_start; used by Reject and by // legacy error frames that don't carry a turn_id. const latestTurnId = useRef(null); + // One-shot guard for the recorded-actions timeline fetch, keyed by + // workflow_run_id — a run's evaluating and final verdict frames share an + // id, so this stops the same run from being fetched twice. + const fetchedActionRunIds = useRef>(new Set()); useEffect(() => { workflowCopilotChatIdRef.current = workflowCopilotChatId; }, [workflowCopilotChatId]); @@ -602,6 +671,71 @@ export function WorkflowCopilotChat({ // The studio focuses a run via ?wr= (not a path param), so the route param is // empty there; an explicit prop grounds the chat in that run and wins. const workflowRunId = workflowRunIdProp ?? routeWorkflowRunId; + // Recorded actions arrive well after block_progress (they're persisted in + // batch at block end), so fetch them once a run reaches adjudication + // instead of waiting on the narrower block_progress/tool-call cadence. + const maybeFetchRecordedActions = useCallback( + async (payload: WorkflowCopilotRunOutcomeUpdate) => { + if (!copilotUxV1Enabled) return; + const runId = payload.workflow_run_id; + if ( + !runId || + !workflowPermanentId || + fetchedActionRunIds.current.has(runId) + ) { + return; + } + const seen = fetchedActionRunIds.current; + seen.add(runId); + while (seen.size > MAX_TURN_SNAPSHOTS) { + const oldest = seen.values().next().value; + if (oldest === undefined) break; + seen.delete(oldest); + } + try { + const client = await getClient(credentialGetter); + const response = await client.get( + `/workflows/${workflowPermanentId}/runs/${runId}/timeline`, + ); + const blocks = collectTimelineBlockActions(response.data ?? []) + .filter((entry) => entry.actions.length > 0) + .map((entry) => ({ + workflowRunBlockId: entry.workflowRunBlockId, + // The API returns actions newest-first; replay must run oldest-first. + actions: [...entry.actions].reverse().map(toRecordedActionSummary), + })); + if (blocks.length === 0) return; + const event: CopilotBlockActionsEvent = { + type: "client_block_actions", + blocks, + receivedAtMs: Date.now(), + }; + applyStoredNarrativeEvent(event); + // The fetch can resolve after the terminal response already froze a + // snapshot into an AI message; patch it in place instead of + // delaying the terminal render on this network call. + setMessages((prev) => + prev.map((message) => { + if (!message.narrative) return message; + const next = applyNarrativeEvent(message.narrative, event); + return next === message.narrative + ? message + : { ...message, narrative: next }; + }), + ); + } catch (error) { + // Best-effort enrichment — the card already shows the real run + // outcome without a recorded-action replay if this fails. + console.error("Failed to fetch recorded actions:", error); + } + }, + [ + applyStoredNarrativeEvent, + copilotUxV1Enabled, + credentialGetter, + workflowPermanentId, + ], + ); const textareaRef = useRef(null); const { getSaveData } = useWorkflowHasChangesStore(); const hasInitializedPosition = useRef(false); @@ -1596,8 +1730,11 @@ export function WorkflowCopilotChat({ case "tool_result": case "narration": case "block_progress": + applyStoredNarrativeEvent(payload); + return false; case "run_outcome": applyStoredNarrativeEvent(payload); + maybeFetchRecordedActions(payload); return false; case "turn_start": { // Move the pre-submit canvas snapshot into the per-turn @@ -1702,6 +1839,7 @@ export function WorkflowCopilotChat({ isBuild, isLiveBrowserReady, liveBrowserSessionId, + maybeFetchRecordedActions, requiresLiveBrowser, stopSpeech, takeSpeechAudioBlob, diff --git a/skyvern-frontend/src/routes/workflows/copilot/actionReveal.test.ts b/skyvern-frontend/src/routes/workflows/copilot/actionReveal.test.ts new file mode 100644 index 000000000..a8a324b2f --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/copilot/actionReveal.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { buildRevealOffsets, revealedCountAt } from "./actionReveal"; + +describe("buildRevealOffsets", () => { + it("clamps a duration below the floor up to 180ms", () => { + expect(buildRevealOffsets([10])).toEqual([180]); + }); + + it("clamps a duration above the ceiling down to 900ms", () => { + expect(buildRevealOffsets([5000])).toEqual([900]); + }); + + it("defaults a null duration to 350ms", () => { + expect(buildRevealOffsets([null])).toEqual([350]); + }); + + it("returns cumulative offsets for a mix of durations", () => { + expect(buildRevealOffsets([200, 300, null])).toEqual([200, 500, 850]); + }); + + it("scales the whole schedule down to the 6s cap once the clamped total exceeds it", () => { + const durations = new Array(30).fill(1000); + const offsets = buildRevealOffsets(durations); + expect(offsets).toHaveLength(30); + expect(offsets[offsets.length - 1]).toBe(6000); + // Each duration clamps to 900ms before scaling; scaling down a uniform + // input yields a uniform step, so every offset is an even multiple. + expect(offsets[0]).toBe(200); + expect(offsets[1]).toBe(400); + }); + + it("does not scale when the clamped total is under the cap", () => { + const offsets = buildRevealOffsets([900, 900, 900]); + expect(offsets).toEqual([900, 1800, 2700]); + }); + + it("returns an empty schedule for no actions", () => { + expect(buildRevealOffsets([])).toEqual([]); + }); +}); + +describe("revealedCountAt", () => { + const offsets = [200, 400, 600]; + + it("reveals nothing at elapsed 0", () => { + expect(revealedCountAt(offsets, 0)).toBe(0); + }); + + it("reveals the rows whose offset has passed, mid-schedule", () => { + expect(revealedCountAt(offsets, 300)).toBe(1); + }); + + it("reveals everything once elapsed reaches the final offset", () => { + expect(revealedCountAt(offsets, 600)).toBe(3); + }); + + it("reveals everything once elapsed exceeds the total", () => { + expect(revealedCountAt(offsets, 10_000)).toBe(3); + }); + + it("reveals nothing for negative elapsed", () => { + expect(revealedCountAt(offsets, -50)).toBe(0); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/copilot/actionReveal.ts b/skyvern-frontend/src/routes/workflows/copilot/actionReveal.ts new file mode 100644 index 000000000..4c538eca1 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/copilot/actionReveal.ts @@ -0,0 +1,45 @@ +// Pure scheduling math for replaying a code block's recorded actions one by +// one instead of dumping them all at once. Kept dependency-free so the +// reveal count can be derived fresh from wall-clock time on every render — +// no per-row timers to restart on collapse/expand or StrictMode remount. +const MIN_STEP_MS = 180; +const MAX_STEP_MS = 900; +const DEFAULT_STEP_MS = 350; +const MAX_TOTAL_MS = 6000; + +// Cumulative end-offset (ms from reveal start) at which each action should +// be showing as done. Each duration is clamped to a sane per-step range, +// then the whole schedule is scaled down if it would exceed the total cap. +export function buildRevealOffsets( + durations: ReadonlyArray, +): number[] { + const clamped = durations.map((duration) => + duration == null + ? DEFAULT_STEP_MS + : Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, duration)), + ); + const total = clamped.reduce((sum, step) => sum + step, 0); + const scale = total > MAX_TOTAL_MS ? MAX_TOTAL_MS / total : 1; + + let cumulative = 0; + return clamped.map((step) => { + cumulative += step * scale; + return cumulative; + }); +} + +// How many actions have finished revealing by `elapsedMs`. A row is done +// once elapsed reaches its offset, so the count lands exactly at the total +// when elapsed reaches the schedule's end. +export function revealedCountAt( + offsets: readonly number[], + elapsedMs: number, +): number { + if (elapsedMs < 0) return 0; + let count = 0; + for (const offset of offsets) { + if (elapsedMs < offset) break; + count += 1; + } + return count; +} diff --git a/skyvern-frontend/src/routes/workflows/copilot/narrativeState.blockActions.test.ts b/skyvern-frontend/src/routes/workflows/copilot/narrativeState.blockActions.test.ts new file mode 100644 index 000000000..aa5becfae --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/copilot/narrativeState.blockActions.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + CopilotBlockActionsEvent, + EMPTY_NARRATIVE, + RecordedActionSummary, + TurnNarrativeState, + applyNarrativeEvent, + hydrateNarrativeFromPayload, +} from "./narrativeState"; +import { + WorkflowCopilotBlockProgressUpdate, + WorkflowCopilotStreamResponseUpdate, + WorkflowCopilotTurnStartUpdate, +} from "./workflowCopilotTypes"; + +const turnStart = (): WorkflowCopilotTurnStartUpdate => ({ + type: "turn_start", + turn_id: "turn-1", + turn_index: 0, + mode: "build", + timestamp: "2026-06-10T00:00:00Z", +}); + +const blockProgress = ( + overrides: Partial & + Pick, +): WorkflowCopilotBlockProgressUpdate => ({ + type: "block_progress", + workflow_run_block_id: `wrb_${overrides.block_label}`, + block_type: "code", + iteration: 0, + timestamp: "2026-06-10T00:00:04Z", + ...overrides, +}); + +const recordedAction = ( + overrides: Partial & + Pick, +): RecordedActionSummary => ({ + label: "Click", + summary: null, + durationMs: 200, + failed: false, + ...overrides, +}); + +const blockActions = ( + overrides: Partial & + Pick, +): CopilotBlockActionsEvent => ({ + type: "client_block_actions", + receivedAtMs: 1_000, + ...overrides, +}); + +function reduce(events: Parameters[1][]) { + return events.reduce( + (state: TurnNarrativeState, event) => applyNarrativeEvent(state, event), + EMPTY_NARRATIVE, + ); +} + +const oneBlockRunning = [ + turnStart(), + blockProgress({ block_label: "open_search", status: "running" }), + blockProgress({ block_label: "open_search", status: "completed" }), +]; + +const twoBlocksRunning = [ + turnStart(), + blockProgress({ block_label: "open_search", status: "running" }), + blockProgress({ block_label: "open_search", status: "completed" }), + blockProgress({ block_label: "search_person", status: "running" }), + blockProgress({ block_label: "search_person", status: "completed" }), +]; + +describe("applyNarrativeEvent — client_block_actions", () => { + it("merges recorded actions into the matched block", () => { + const s = reduce([ + ...oneBlockRunning, + blockActions({ + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a1" })], + }, + ], + }), + ]); + const block = s.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toHaveLength(1); + expect(block.recordedActions![0]!.actionId).toBe("a1"); + expect(block.recordedActionsAt).toBe(1_000); + }); + + it("is idempotent — a duplicate dispatch for the same block keeps the first application", () => { + const first = blockActions({ + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a1" })], + }, + ], + }); + const second = blockActions({ + receivedAtMs: 99_999, + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a2" })], + }, + ], + }); + const s = reduce([...oneBlockRunning, first, second]); + const block = s.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toHaveLength(1); + expect(block.recordedActions![0]!.actionId).toBe("a1"); + expect(block.recordedActionsAt).toBe(1_000); + }); + + it("ignores an entry whose workflow_run_block_id matches no known block", () => { + const s = reduce([ + ...oneBlockRunning, + blockActions({ + blocks: [ + { + workflowRunBlockId: "wrb_does_not_exist", + actions: [recordedAction({ actionId: "a1" })], + }, + ], + }), + ]); + const block = s.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toBeUndefined(); + }); + + it("ignores an entry with an empty actions array", () => { + const s = reduce([ + ...oneBlockRunning, + blockActions({ + blocks: [{ workflowRunBlockId: "wrb_open_search", actions: [] }], + }), + ]); + const block = s.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toBeUndefined(); + }); + + it("returns the same state reference when nothing matches, so a message from another turn/run doesn't re-render", () => { + const before = reduce(oneBlockRunning); + const after = applyNarrativeEvent( + before, + blockActions({ + blocks: [{ workflowRunBlockId: "wrb_does_not_exist", actions: [] }], + }), + ); + // WorkflowCopilotChat.tsx's per-message patch on fetch arrival relies on + // this identity to skip messages the event doesn't touch. + expect(after).toBe(before); + }); + + it("preserves recordedActions across a late/replayed block_progress for the same block", () => { + const withActions = reduce([ + ...oneBlockRunning, + blockActions({ + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a1" })], + }, + ], + }), + ]); + const replayed = applyNarrativeEvent( + withActions, + blockProgress({ block_label: "open_search", status: "completed" }), + ); + const block = replayed.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toHaveLength(1); + expect(block.recordedActionsAt).toBe(1_000); + }); + + it("carries recordedActions through a response event that hydrates from a real narrative_payload", () => { + const withActions = reduce([ + ...oneBlockRunning, + blockActions({ + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a1" })], + }, + ], + }), + ]); + const responseEvent: WorkflowCopilotStreamResponseUpdate = { + type: "response", + workflow_copilot_chat_id: "chat_1", + message: "Done", + response_time: "2026-06-10T00:01:00Z", + proposal_disposition: "no_proposal", + turn_id: "turn-1", + narrative_payload: { + turnId: "turn-1", + turnIndex: 0, + mode: "build", + terminal: "response", + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + label: "open_search", + blockType: "code", + state: "completed", + lastSeenIteration: 0, + activity: [], + startedAt: "2026-06-10T00:00:04Z", + endedAt: "2026-06-10T00:01:00Z", + }, + ], + }, + }; + const after = applyNarrativeEvent(withActions, responseEvent); + const block = after.blocks.find((b) => b.label === "open_search")!; + expect(block.recordedActions).toHaveLength(1); + expect(block.recordedActionsAt).toBe(1_000); + }); + + it("staggers a second block's reveal start past the first block's own schedule total", () => { + const s = reduce([ + ...twoBlocksRunning, + blockActions({ + receivedAtMs: 5_000, + blocks: [ + { + workflowRunBlockId: "wrb_open_search", + actions: [recordedAction({ actionId: "a1", durationMs: 200 })], + }, + { + workflowRunBlockId: "wrb_search_person", + actions: [recordedAction({ actionId: "a2", durationMs: 300 })], + }, + ], + }), + ]); + const first = s.blocks.find((b) => b.label === "open_search")!; + const second = s.blocks.find((b) => b.label === "search_person")!; + expect(first.recordedActionsAt).toBe(5_000); + // second block starts after the first block's own (clamped) 200ms total + expect(second.recordedActionsAt).toBe(5_200); + }); +}); + +describe("hydrateNarrativeFromPayload — client_block_actions fields", () => { + it("never sets recordedActions from a BE-built history payload", () => { + const hydrated = hydrateNarrativeFromPayload({ + turnId: "turn-1", + turnIndex: 0, + mode: "build", + terminal: "response", + blocks: [ + { + label: "open_search", + blockType: "code", + state: "completed", + lastSeenIteration: 0, + activity: [], + startedAt: "2026-06-10T00:00:04Z", + endedAt: "2026-06-10T00:01:00Z", + }, + ], + })!; + expect(hydrated.blocks[0]!.recordedActions).toBeUndefined(); + expect(hydrated.blocks[0]!.recordedActionsAt).toBeUndefined(); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/copilot/narrativeState.ts b/skyvern-frontend/src/routes/workflows/copilot/narrativeState.ts index eae284f59..d6dbe814c 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/narrativeState.ts +++ b/skyvern-frontend/src/routes/workflows/copilot/narrativeState.ts @@ -3,6 +3,7 @@ // component without re-evaluating reducer state, and so the reducer can be // exercised under vitest without a JSX runtime. +import { buildRevealOffsets } from "./actionReveal"; import { CopilotResponseType, ProposalDisposition, @@ -19,6 +20,28 @@ import { WorkflowCopilotWorkflowDraftUpdate, } from "./workflowCopilotTypes"; +// A code block's recorded actions, fetched once from the run timeline after +// a run_outcome frame arrives (block_progress carries no action detail). +export interface RecordedActionSummary { + actionId: string; + label: string; + summary: string | null; + durationMs: number | null; + failed: boolean; +} + +// Client-synthesized event (never sent by the backend) that carries the +// fetched recorded actions into the reducer so the reveal schedule can be +// derived the same way as every other narrative update. +export interface CopilotBlockActionsEvent { + type: "client_block_actions"; + blocks: Array<{ + workflowRunBlockId: string; + actions: RecordedActionSummary[]; + }>; + receivedAtMs: number; +} + // Discriminated union of every event the reducer below consumes. The bubble // derives all of its rendering from these payloads. export type NarrativeEvent = @@ -32,7 +55,8 @@ export type NarrativeEvent = | WorkflowCopilotStreamErrorUpdate | WorkflowCopilotNarrationUpdate | WorkflowCopilotToolCallUpdate - | WorkflowCopilotToolResultUpdate; + | WorkflowCopilotToolResultUpdate + | CopilotBlockActionsEvent; // Block lifecycle states as observed via block_progress. The bubble groups // failed-style states (failed, terminated, timed_out, canceled) under one @@ -70,6 +94,12 @@ export interface BlockState { // ``done · 0:14``-style elapsed pill in the card. startedAt: string | null; endedAt: string | null; + // Recorded actions fetched post-run for progressive reveal. Undefined + // until the one-shot fetch resolves; set at most once (idempotent). + recordedActions?: RecordedActionSummary[]; + // Epoch ms this block's reveal schedule starts counting from — staggered + // past preceding blocks' schedules so a multi-block run reveals in order. + recordedActionsAt?: number; } export interface ActivityEntry { @@ -418,10 +448,13 @@ export function applyNarrativeEvent( label: event.block_label, blockType: event.block_type, state: incomingState, - // A late or replayed block_progress must not wipe a recorded verdict; - // lifecycle frames never carry outcome, so always keep the prior one. + // A late or replayed block_progress must not wipe a recorded verdict + // or an already-fetched action replay; lifecycle frames never carry + // either, so always keep the prior values. outcome: previousBlock?.outcome, outcomeReason: previousBlock?.outcomeReason, + recordedActions: previousBlock?.recordedActions, + recordedActionsAt: previousBlock?.recordedActionsAt, lastSeenIteration: event.iteration, activity: previousBlock?.activity ?? [], startedAt, @@ -451,6 +484,30 @@ export function applyNarrativeEvent( return { ...prev, blocks }; } + case "client_block_actions": { + // Idempotent merge: a block's recordedActions is set at most once, so + // a duplicate fetch response (or a re-dispatched event) is a no-op. + // Stagger each matched block's reveal start past the running total of + // its predecessors' own schedules, so a multi-block run replays in + // execution order instead of all at once. + let carry = 0; + let changed = false; + const blocks = prev.blocks.map((b) => { + if (b.recordedActions !== undefined) return b; + const match = event.blocks.find( + (entry) => entry.workflowRunBlockId === b.workflowRunBlockId, + ); + if (!match || match.actions.length === 0) return b; + changed = true; + const recordedActionsAt = event.receivedAtMs + carry; + const durations = match.actions.map((a) => a.durationMs); + const offsets = buildRevealOffsets(durations); + carry += offsets[offsets.length - 1] ?? 0; + return { ...b, recordedActions: match.actions, recordedActionsAt }; + }); + return changed ? { ...prev, blocks } : prev; + } + case "tool_call": { const entry = buildActivityFromToolCall(event); if (!entry) return prev; @@ -486,8 +543,28 @@ export function applyNarrativeEvent( case "response": { const hydrated = hydrateNarrativeFromPayload(event.narrative_payload); if (hydrated) { + // The backend payload never carries recordedActions/recordedActionsAt + // (client-only replay data); carry them over from the prior live + // blocks so a fetch that resolved before this terminal frame + // survives hydration instead of being silently dropped. + const recordedById = new Map( + prev.blocks + .filter((b) => b.recordedActions !== undefined) + .map((b) => [b.workflowRunBlockId, b] as const), + ); + const blocks = hydrated.blocks.map((b) => { + const carried = recordedById.get(b.workflowRunBlockId); + return carried + ? { + ...b, + recordedActions: carried.recordedActions, + recordedActionsAt: carried.recordedActionsAt, + } + : b; + }); return { ...hydrated, + blocks, responseType: event.response_type ?? hydrated.responseType, cancelled: event.cancelled ?? hydrated.cancelled, proposalDisposition: diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/useShimmerText.ts b/skyvern-frontend/src/routes/workflows/workflowRun/useShimmerText.ts index b71b48cf9..cbfa7d953 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/useShimmerText.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/useShimmerText.ts @@ -8,7 +8,7 @@ function useShimmerText(active: boolean) { }, []); useEffect(() => { - if (!active || !element) return; + if (!active || !element || typeof element.animate !== "function") return; // Apply static styles for background-clip: text element.style.background = diff --git a/skyvern-frontend/src/util/featureFlags.ts b/skyvern-frontend/src/util/featureFlags.ts index e3eab8bbd..90c399b42 100644 --- a/skyvern-frontend/src/util/featureFlags.ts +++ b/skyvern-frontend/src/util/featureFlags.ts @@ -25,6 +25,10 @@ export const WORKFLOWS_DIRECTORY_TREE_FLAG = "WORKFLOWS_DIRECTORY_TREE"; // Not enrolled reads as disabled, so the default stays the legacy editor. export const WORKFLOW_STUDIO_FLAG = "workflow_studio_v2"; +// Gates progressive recorded-action reveal in copilot verify cards. +// Not enrolled reads as disabled, so the default stays the pre-reveal card. +export const COPILOT_UX_V1_FLAG = "copilot_ux_v1"; + // Opt-in (0% base rollout) gating the analytics org-zoom group-by lens // (run metadata / workflow tag). Off ⇒ the dashboard renders as today with no // group-by control. See cloud_docs/analytics/GROUP_BY_LENS_DESIGN.md. diff --git a/skyvern-frontend/tailwind.config.js b/skyvern-frontend/tailwind.config.js index 978450926..7e4bee7f9 100644 --- a/skyvern-frontend/tailwind.config.js +++ b/skyvern-frontend/tailwind.config.js @@ -146,6 +146,14 @@ export default { from: { boxShadow: "0 0 0 0 hsl(var(--foreground) / 0.35)" }, to: { boxShadow: "0 0 0 8px hsl(var(--foreground) / 0)" }, }, + "copilot-row-flash-success": { + "0%, 30%": { backgroundColor: "rgba(16, 185, 129, 0.22)" }, + "100%": { backgroundColor: "transparent" }, + }, + "copilot-row-flash-error": { + "0%, 30%": { backgroundColor: "rgba(244, 63, 94, 0.22)" }, + "100%": { backgroundColor: "transparent" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -159,6 +167,8 @@ export default { "collapsible-up-fade 0.22s cubic-bezier(0.22, 1, 0.36, 1)", glow: "glow 2.5s ease-in-out infinite", "analytics-pulse": "analytics-pulse-ring 0.6s ease-out 1", + "copilot-row-flash-success": "copilot-row-flash-success 0.6s ease-out", + "copilot-row-flash-error": "copilot-row-flash-error 0.6s ease-out", }, }, },