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 { 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<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 {
|
||||
|
|
@ -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<boolean | null>(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) => (
|
||||
<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 ? (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} 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<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 =
|
||||
| "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<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(() => {
|
||||
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<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 { 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ function useShimmerText<T extends HTMLElement = HTMLElement>(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 =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue