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

This commit is contained in:
Celal Zamanoğlu 2026-07-09 18:57:07 +03:00 committed by GitHub
parent 59fd1982d8
commit 3aaffb667d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1312 additions and 9 deletions

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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());
});
});

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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:

View file

@ -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 =

View file

@ -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.

View file

@ -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",
}, },
}, },
}, },