mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-11339): progressive action reveal for copilot verify cards (#7232)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
This commit is contained in:
parent
59fd1982d8
commit
3aaffb667d
11 changed files with 1312 additions and 9 deletions
|
|
@ -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> = {},
|
||||||
|
): 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(
|
||||||
|
<NarrativeView
|
||||||
|
turn={inFlightTurnWithBlock(verifyingBlockWithActions(actions, NOW))}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<NarrativeView
|
||||||
|
turn={inFlightTurnWithBlock(
|
||||||
|
verifyingBlockWithActions(actions, NOW - 60_000),
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<NarrativeView turn={terminalTurn} />);
|
||||||
|
// 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(<NarrativeView turn={inFlightTurnWithBlock(block)} />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { buildRevealOffsets, revealedCountAt } from "./actionReveal";
|
||||||
import {
|
import {
|
||||||
ActivityEntry,
|
ActivityEntry,
|
||||||
BlockState,
|
BlockState,
|
||||||
|
RecordedActionSummary,
|
||||||
TurnNarrativeState,
|
TurnNarrativeState,
|
||||||
TurnSummary,
|
TurnSummary,
|
||||||
computeTurnSummary,
|
computeTurnSummary,
|
||||||
|
|
@ -13,6 +15,11 @@ import {
|
||||||
parseUtcIsoMs,
|
parseUtcIsoMs,
|
||||||
toolActivityDisplayLabel,
|
toolActivityDisplayLabel,
|
||||||
} from "./narrativeState";
|
} 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 {
|
interface BlockPalette {
|
||||||
fg: string;
|
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);
|
const [, setTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
const id = setInterval(() => setTick((t) => t + 1), intervalMs);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [active]);
|
}, [active, intervalMs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FRecordedActionRow({
|
||||||
|
action,
|
||||||
|
revealing,
|
||||||
|
flash,
|
||||||
|
}: {
|
||||||
|
action: RecordedActionSummary;
|
||||||
|
revealing: boolean;
|
||||||
|
flash: boolean;
|
||||||
|
}) {
|
||||||
|
const shimmerRef = useShimmerText<HTMLSpanElement>(revealing);
|
||||||
|
if (revealing) {
|
||||||
|
return (
|
||||||
|
<FSubRow glyph={<Spinner small />} glyphClass="text-blue-300">
|
||||||
|
<span ref={shimmerRef} className="text-slate-200">
|
||||||
|
{action.label}
|
||||||
|
</span>
|
||||||
|
{action.summary ? (
|
||||||
|
<span className="text-slate-500"> · {action.summary}</span>
|
||||||
|
) : null}
|
||||||
|
</FSubRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const flashClass = flash
|
||||||
|
? action.failed
|
||||||
|
? "animate-copilot-row-flash-error"
|
||||||
|
: "animate-copilot-row-flash-success"
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<FSubRow
|
||||||
|
glyph={action.failed ? "✕" : "✓"}
|
||||||
|
glyphClass={action.failed ? "text-rose-300" : "text-emerald-300"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${action.failed ? "text-rose-200" : "text-slate-200"} ${flashClass}`}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</span>
|
||||||
|
{action.summary ? (
|
||||||
|
<span className="text-slate-500"> · {action.summary}</span>
|
||||||
|
) : null}
|
||||||
|
</FSubRow>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FBlockRunProps {
|
interface FBlockRunProps {
|
||||||
|
|
@ -247,11 +298,41 @@ function FBlockRun({ block, turnEnded, onSelect }: FBlockRunProps) {
|
||||||
? "bg-rose-500/15"
|
? "bg-rose-500/15"
|
||||||
: "bg-slate-elevation3";
|
: "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<boolean | null>(null);
|
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||||
const defaultOpen = isRunning || isFail;
|
const defaultOpen = isRunning || isFail || (hasActions && !turnEnded);
|
||||||
const open = userOpen === null ? defaultOpen : userOpen;
|
const open = userOpen === null ? defaultOpen : userOpen;
|
||||||
const toggleable = isOk || isOutcomeNotShown || isVerifying || isRanNeutral;
|
const toggleable = isOk || isOutcomeNotShown || isVerifying || isRanNeutral;
|
||||||
useSecondTick(isRunning);
|
useTick(isRunning);
|
||||||
|
useTick(hasActions && (replayingAction || elapsedReveal < totalMs), 150);
|
||||||
const elapsed = formatElapsed(block.startedAt, block.endedAt);
|
const elapsed = formatElapsed(block.startedAt, block.endedAt);
|
||||||
const live = isRunning ? liveElapsed(block.startedAt) : null;
|
const live = isRunning ? liveElapsed(block.startedAt) : null;
|
||||||
const statusText = isOk
|
const statusText = isOk
|
||||||
|
|
@ -354,6 +435,21 @@ function FBlockRun({ block, turnEnded, onSelect }: FBlockRunProps) {
|
||||||
{block.activity.map((entry) => (
|
{block.activity.map((entry) => (
|
||||||
<ActivityRow key={entry.id} entry={entry} />
|
<ActivityRow key={entry.id} entry={entry} />
|
||||||
))}
|
))}
|
||||||
|
{hasActions
|
||||||
|
? recordedActions!
|
||||||
|
.slice(0, visibleActionCount)
|
||||||
|
.map((action, i) => (
|
||||||
|
<FRecordedActionRow
|
||||||
|
key={action.actionId}
|
||||||
|
action={action}
|
||||||
|
revealing={replayingAction && i === revealedCount}
|
||||||
|
flash={
|
||||||
|
i < revealedCount &&
|
||||||
|
elapsedReveal - offsets[i]! < FLASH_WINDOW_MS
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
{isFail ? (
|
{isFail ? (
|
||||||
<div className="mt-1 flex items-start gap-2 rounded-md border border-rose-400/30 bg-rose-500/10 px-2.5 py-1.5">
|
<div className="mt-1 flex items-start gap-2 rounded-md border border-rose-400/30 bg-rose-500/10 px-2.5 py-1.5">
|
||||||
<span className="text-[11px] font-bold text-rose-300">✕</span>
|
<span className="text-[11px] font-bold text-rose-300">✕</span>
|
||||||
|
|
|
||||||
|
|
@ -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<void>((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<string, unknown> | 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<typeof import("react-router-dom")>();
|
||||||
|
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<string, boolean> = {
|
||||||
|
ENABLE_WORKFLOW_COPILOT_V2: true,
|
||||||
|
WORKFLOW_COPILOT_CODE_BLOCK_MODE: false,
|
||||||
|
CODE_BLOCK_ACCESS: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function chatUi() {
|
||||||
|
return (
|
||||||
|
<FeatureFlagContext.Provider value={(name) => BOOLEAN_FLAGS[name]}>
|
||||||
|
<FeatureFlagValueContext.Provider value={() => undefined}>
|
||||||
|
<WorkflowCopilotChat />
|
||||||
|
</FeatureFlagValueContext.Provider>
|
||||||
|
</FeatureFlagContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<string, unknown>> = {}) => ({
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
memo,
|
memo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getClient } from "@/api/AxiosClient";
|
import { getClient } from "@/api/AxiosClient";
|
||||||
|
import { ActionsApiResponse, getReadableActionType } from "@/api/types";
|
||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,6 +25,10 @@ import { useCopilotHeaderStore } from "@/store/useCopilotHeaderStore";
|
||||||
import { usePasteSkillHintStore } from "@/store/usePasteSkillHintStore";
|
import { usePasteSkillHintStore } from "@/store/usePasteSkillHintStore";
|
||||||
import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
|
import { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
|
||||||
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||||
|
import {
|
||||||
|
isBlockItem,
|
||||||
|
WorkflowRunTimelineItem,
|
||||||
|
} from "@/routes/workflows/types/workflowRunTypes";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import { getSseClient } from "@/api/sse";
|
import { getSseClient } from "@/api/sse";
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,8 +66,10 @@ import { NarrativeView } from "./NarrativeView";
|
||||||
import { DiffCard, shouldShowDiffCard } from "./cards/DiffCard";
|
import { DiffCard, shouldShowDiffCard } from "./cards/DiffCard";
|
||||||
import { FixCard, shouldShowFixCard } from "./cards/FixCard";
|
import { FixCard, shouldShowFixCard } from "./cards/FixCard";
|
||||||
import {
|
import {
|
||||||
|
CopilotBlockActionsEvent,
|
||||||
EMPTY_NARRATIVE,
|
EMPTY_NARRATIVE,
|
||||||
NarrativeEvent,
|
NarrativeEvent,
|
||||||
|
RecordedActionSummary,
|
||||||
TurnNarrativeState,
|
TurnNarrativeState,
|
||||||
applyNarrativeEvent,
|
applyNarrativeEvent,
|
||||||
hydrateHistoryNarrative,
|
hydrateHistoryNarrative,
|
||||||
|
|
@ -72,6 +79,8 @@ import { computeFollowSignature, useStickToBottom } from "./useStickToBottom";
|
||||||
import { useSpeechToTextField } from "@/hooks/useSpeechToTextField";
|
import { useSpeechToTextField } from "@/hooks/useSpeechToTextField";
|
||||||
import { SpeechInputButton } from "@/components/SpeechInputButton";
|
import { SpeechInputButton } from "@/components/SpeechInputButton";
|
||||||
import { useFeatureFlag, useFeatureFlagValue } from "@/hooks/useFeatureFlag";
|
import { useFeatureFlag, useFeatureFlagValue } from "@/hooks/useFeatureFlag";
|
||||||
|
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||||
|
import { COPILOT_UX_V1_FLAG } from "@/util/featureFlags";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -84,6 +93,60 @@ import { cn } from "@/util/utils";
|
||||||
// handful of turns; this ceiling guards a runaway long-running chat.
|
// handful of turns; this ceiling guards a runaway long-running chat.
|
||||||
const MAX_TURN_SNAPSHOTS = 20;
|
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<string, unknown>).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<WorkflowRunTimelineItem>,
|
||||||
|
): 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 =
|
type ComposerDefaultVariant =
|
||||||
| "build"
|
| "build"
|
||||||
| "build_code"
|
| "build_code"
|
||||||
|
|
@ -448,6 +511,8 @@ export function WorkflowCopilotChat({
|
||||||
const copilotV2Flag = useFeatureFlag("ENABLE_WORKFLOW_COPILOT_V2");
|
const copilotV2Flag = useFeatureFlag("ENABLE_WORKFLOW_COPILOT_V2");
|
||||||
const codeBlockModeFlag = useFeatureFlag("WORKFLOW_COPILOT_CODE_BLOCK_MODE");
|
const codeBlockModeFlag = useFeatureFlag("WORKFLOW_COPILOT_CODE_BLOCK_MODE");
|
||||||
const codeBlockAccessFlag = useFeatureFlag("CODE_BLOCK_ACCESS");
|
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 copilotV2Enabled = copilotV2Flag === true;
|
||||||
const codeBlockModeEnabled =
|
const codeBlockModeEnabled =
|
||||||
codeBlockModeFlag === true && codeBlockAccessFlag === true;
|
codeBlockModeFlag === true && codeBlockAccessFlag === true;
|
||||||
|
|
@ -559,6 +624,10 @@ export function WorkflowCopilotChat({
|
||||||
// Most recent turn_id observed via turn_start; used by Reject and by
|
// Most recent turn_id observed via turn_start; used by Reject and by
|
||||||
// legacy error frames that don't carry a turn_id.
|
// legacy error frames that don't carry a turn_id.
|
||||||
const latestTurnId = useRef<string | null>(null);
|
const latestTurnId = useRef<string | null>(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<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workflowCopilotChatIdRef.current = workflowCopilotChatId;
|
workflowCopilotChatIdRef.current = workflowCopilotChatId;
|
||||||
}, [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
|
// 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.
|
// empty there; an explicit prop grounds the chat in that run and wins.
|
||||||
const workflowRunId = workflowRunIdProp ?? routeWorkflowRunId;
|
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<WorkflowRunTimelineItem[]>(
|
||||||
|
`/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<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const { getSaveData } = useWorkflowHasChangesStore();
|
const { getSaveData } = useWorkflowHasChangesStore();
|
||||||
const hasInitializedPosition = useRef(false);
|
const hasInitializedPosition = useRef(false);
|
||||||
|
|
@ -1596,8 +1730,11 @@ export function WorkflowCopilotChat({
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
case "narration":
|
case "narration":
|
||||||
case "block_progress":
|
case "block_progress":
|
||||||
|
applyStoredNarrativeEvent(payload);
|
||||||
|
return false;
|
||||||
case "run_outcome":
|
case "run_outcome":
|
||||||
applyStoredNarrativeEvent(payload);
|
applyStoredNarrativeEvent(payload);
|
||||||
|
maybeFetchRecordedActions(payload);
|
||||||
return false;
|
return false;
|
||||||
case "turn_start": {
|
case "turn_start": {
|
||||||
// Move the pre-submit canvas snapshot into the per-turn
|
// Move the pre-submit canvas snapshot into the per-turn
|
||||||
|
|
@ -1702,6 +1839,7 @@ export function WorkflowCopilotChat({
|
||||||
isBuild,
|
isBuild,
|
||||||
isLiveBrowserReady,
|
isLiveBrowserReady,
|
||||||
liveBrowserSessionId,
|
liveBrowserSessionId,
|
||||||
|
maybeFetchRecordedActions,
|
||||||
requiresLiveBrowser,
|
requiresLiveBrowser,
|
||||||
stopSpeech,
|
stopSpeech,
|
||||||
takeSpeechAudioBlob,
|
takeSpeechAudioBlob,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 | null>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|
@ -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<WorkflowCopilotBlockProgressUpdate> &
|
||||||
|
Pick<WorkflowCopilotBlockProgressUpdate, "block_label" | "status">,
|
||||||
|
): 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<RecordedActionSummary> &
|
||||||
|
Pick<RecordedActionSummary, "actionId">,
|
||||||
|
): RecordedActionSummary => ({
|
||||||
|
label: "Click",
|
||||||
|
summary: null,
|
||||||
|
durationMs: 200,
|
||||||
|
failed: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockActions = (
|
||||||
|
overrides: Partial<CopilotBlockActionsEvent> &
|
||||||
|
Pick<CopilotBlockActionsEvent, "blocks">,
|
||||||
|
): CopilotBlockActionsEvent => ({
|
||||||
|
type: "client_block_actions",
|
||||||
|
receivedAtMs: 1_000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function reduce(events: Parameters<typeof applyNarrativeEvent>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// component without re-evaluating reducer state, and so the reducer can be
|
// component without re-evaluating reducer state, and so the reducer can be
|
||||||
// exercised under vitest without a JSX runtime.
|
// exercised under vitest without a JSX runtime.
|
||||||
|
|
||||||
|
import { buildRevealOffsets } from "./actionReveal";
|
||||||
import {
|
import {
|
||||||
CopilotResponseType,
|
CopilotResponseType,
|
||||||
ProposalDisposition,
|
ProposalDisposition,
|
||||||
|
|
@ -19,6 +20,28 @@ import {
|
||||||
WorkflowCopilotWorkflowDraftUpdate,
|
WorkflowCopilotWorkflowDraftUpdate,
|
||||||
} from "./workflowCopilotTypes";
|
} 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
|
// Discriminated union of every event the reducer below consumes. The bubble
|
||||||
// derives all of its rendering from these payloads.
|
// derives all of its rendering from these payloads.
|
||||||
export type NarrativeEvent =
|
export type NarrativeEvent =
|
||||||
|
|
@ -32,7 +55,8 @@ export type NarrativeEvent =
|
||||||
| WorkflowCopilotStreamErrorUpdate
|
| WorkflowCopilotStreamErrorUpdate
|
||||||
| WorkflowCopilotNarrationUpdate
|
| WorkflowCopilotNarrationUpdate
|
||||||
| WorkflowCopilotToolCallUpdate
|
| WorkflowCopilotToolCallUpdate
|
||||||
| WorkflowCopilotToolResultUpdate;
|
| WorkflowCopilotToolResultUpdate
|
||||||
|
| CopilotBlockActionsEvent;
|
||||||
|
|
||||||
// Block lifecycle states as observed via block_progress. The bubble groups
|
// Block lifecycle states as observed via block_progress. The bubble groups
|
||||||
// failed-style states (failed, terminated, timed_out, canceled) under one
|
// 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.
|
// ``done · 0:14``-style elapsed pill in the card.
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
endedAt: 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 {
|
export interface ActivityEntry {
|
||||||
|
|
@ -418,10 +448,13 @@ export function applyNarrativeEvent(
|
||||||
label: event.block_label,
|
label: event.block_label,
|
||||||
blockType: event.block_type,
|
blockType: event.block_type,
|
||||||
state: incomingState,
|
state: incomingState,
|
||||||
// A late or replayed block_progress must not wipe a recorded verdict;
|
// A late or replayed block_progress must not wipe a recorded verdict
|
||||||
// lifecycle frames never carry outcome, so always keep the prior one.
|
// or an already-fetched action replay; lifecycle frames never carry
|
||||||
|
// either, so always keep the prior values.
|
||||||
outcome: previousBlock?.outcome,
|
outcome: previousBlock?.outcome,
|
||||||
outcomeReason: previousBlock?.outcomeReason,
|
outcomeReason: previousBlock?.outcomeReason,
|
||||||
|
recordedActions: previousBlock?.recordedActions,
|
||||||
|
recordedActionsAt: previousBlock?.recordedActionsAt,
|
||||||
lastSeenIteration: event.iteration,
|
lastSeenIteration: event.iteration,
|
||||||
activity: previousBlock?.activity ?? [],
|
activity: previousBlock?.activity ?? [],
|
||||||
startedAt,
|
startedAt,
|
||||||
|
|
@ -451,6 +484,30 @@ export function applyNarrativeEvent(
|
||||||
return { ...prev, blocks };
|
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": {
|
case "tool_call": {
|
||||||
const entry = buildActivityFromToolCall(event);
|
const entry = buildActivityFromToolCall(event);
|
||||||
if (!entry) return prev;
|
if (!entry) return prev;
|
||||||
|
|
@ -486,8 +543,28 @@ export function applyNarrativeEvent(
|
||||||
case "response": {
|
case "response": {
|
||||||
const hydrated = hydrateNarrativeFromPayload(event.narrative_payload);
|
const hydrated = hydrateNarrativeFromPayload(event.narrative_payload);
|
||||||
if (hydrated) {
|
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 {
|
return {
|
||||||
...hydrated,
|
...hydrated,
|
||||||
|
blocks,
|
||||||
responseType: event.response_type ?? hydrated.responseType,
|
responseType: event.response_type ?? hydrated.responseType,
|
||||||
cancelled: event.cancelled ?? hydrated.cancelled,
|
cancelled: event.cancelled ?? hydrated.cancelled,
|
||||||
proposalDisposition:
|
proposalDisposition:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ function useShimmerText<T extends HTMLElement = HTMLElement>(active: boolean) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || !element) return;
|
if (!active || !element || typeof element.animate !== "function") return;
|
||||||
|
|
||||||
// Apply static styles for background-clip: text
|
// Apply static styles for background-clip: text
|
||||||
element.style.background =
|
element.style.background =
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Not enrolled reads as disabled, so the default stays the legacy editor.
|
||||||
export const WORKFLOW_STUDIO_FLAG = "workflow_studio_v2";
|
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
|
// 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
|
// (run metadata / workflow tag). Off ⇒ the dashboard renders as today with no
|
||||||
// group-by control. See cloud_docs/analytics/GROUP_BY_LENS_DESIGN.md.
|
// group-by control. See cloud_docs/analytics/GROUP_BY_LENS_DESIGN.md.
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,14 @@ export default {
|
||||||
from: { boxShadow: "0 0 0 0 hsl(var(--foreground) / 0.35)" },
|
from: { boxShadow: "0 0 0 0 hsl(var(--foreground) / 0.35)" },
|
||||||
to: { boxShadow: "0 0 0 8px hsl(var(--foreground) / 0)" },
|
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: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"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)",
|
"collapsible-up-fade 0.22s cubic-bezier(0.22, 1, 0.36, 1)",
|
||||||
glow: "glow 2.5s ease-in-out infinite",
|
glow: "glow 2.5s ease-in-out infinite",
|
||||||
"analytics-pulse": "analytics-pulse-ring 0.6s ease-out 1",
|
"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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue