feat: expose Scene 10 live replay launch

This commit is contained in:
lda
2026-07-12 10:53:46 +07:00 Verified
parent b1fed7ebf8
commit 1fb766a79c
5 changed files with 351 additions and 2 deletions
@@ -0,0 +1,147 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { TimelineAgentController, TimelineAgentMode } from "../demo/agent/timelineAgent.js";
import { initialDemoTimelineState } from "../demo/timeline/reducer.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
const target = "http://127.0.0.1:8765/rpc";
const demoController = (
phase: DemoTimelineController["state"]["phase"] = "ready",
): DemoTimelineController => ({
state: { ...initialDemoTimelineState, phase },
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: null,
canStart: true,
setMode: vi.fn(),
start: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
});
const timelineAgent = (
runPreparedWorkflow: (mode?: TimelineAgentMode) => Promise<void>,
overrides: Partial<TimelineAgentController> = {},
): TimelineAgentController => ({
messages: [],
canRun: true,
canRunLive: true,
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
...overrides,
});
const readyStatus: PresentationTargetHealth = {
kind: "ready",
target,
label: "Live target ready",
detail: "127.0.0.1:8765",
};
const replayStatus: PresentationTargetHealth = {
kind: "replay",
label: "Replay evidence",
detail: "reviewed recording",
};
const failedStatus: PresentationTargetHealth = {
kind: "failed",
target,
label: "Replay fallback",
detail: "connection refused",
};
const checkingStatus: PresentationTargetHealth = {
kind: "checking",
target,
label: "Live target configured",
detail: "checking",
};
const renderControl = (options: {
readonly status?: PresentationTargetHealth;
readonly liveTargetReady?: boolean;
readonly demo?: DemoTimelineController;
readonly agent?: TimelineAgentController;
} = {}) => render(
<DemoRunLaunchControl
status={options.status ?? readyStatus}
liveTargetReady={options.liveTargetReady ?? true}
demo={options.demo ?? demoController()}
timelineAgent={options.agent ?? timelineAgent(async () => {})}
retryHealth={vi.fn()}
/>,
);
afterEach(() => cleanup());
describe("DemoRunLaunchControl", () => {
it("launches live from a healthy target", async () => {
const run = vi.fn(async () => {});
renderControl({ agent: timelineAgent(run) });
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
expect(run).toHaveBeenCalledWith("live");
});
it("keeps the live action available from a direct replay view", async () => {
const run = vi.fn(async () => {});
renderControl({
status: replayStatus,
liveTargetReady: true,
agent: timelineAgent(run),
});
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
expect(run).toHaveBeenCalledWith("live");
});
it("offers explicit replay and retry after health failure", async () => {
const run = vi.fn(async () => {});
const retry = vi.fn();
render(
<DemoRunLaunchControl
status={failedStatus}
liveTargetReady={false}
demo={demoController()}
timelineAgent={timelineAgent(run)}
retryHealth={retry}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Play replay walkthrough" }));
await userEvent.click(screen.getByRole("button", { name: "Retry live service" }));
expect(run).toHaveBeenCalledWith("replay");
expect(retry).toHaveBeenCalledOnce();
});
it("keeps the action visible but disabled while health is checking", () => {
renderControl({ status: checkingStatus, liveTargetReady: false });
expect(screen.getByRole("button", { name: "Checking live service" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Retry live service" })).toBeDisabled();
});
it("disables duplicate starts while the live timeline is running", () => {
renderControl({ status: { ...readyStatus, kind: "active", label: "Live run active", detail: "operations sent" }, demo: demoController("running") });
expect(screen.getByRole("button", { name: "Live workflow running" })).toBeDisabled();
});
});
@@ -0,0 +1,66 @@
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
export type DemoRunLaunchControlProps = {
readonly status: PresentationTargetHealth;
readonly liveTargetReady: boolean;
readonly demo: DemoTimelineController;
readonly timelineAgent: TimelineAgentController | undefined;
readonly retryHealth: () => void;
};
export const DemoRunLaunchControl = ({
status,
liveTargetReady,
demo,
timelineAgent,
retryHealth,
}: DemoRunLaunchControlProps) => {
if (!timelineAgent) return null;
const isChecking = status.kind === "checking";
const isRunning = demo.inFlight || demo.state.phase === "running" || status.kind === "active";
const launchLive = liveTargetReady;
const launchLabel = isChecking
? "Checking live service"
: isRunning && launchLive
? "Live workflow running"
: launchLive
? "Run prepared workflow"
: "Play replay walkthrough";
const canLaunch = launchLive ? timelineAgent.canRunLive : timelineAgent.canRun;
return (
<section
className="demo-run-launch-control"
data-target-kind={status.kind}
data-launch-mode={launchLive ? "live" : "replay"}
aria-label="prepared workflow launch"
>
<div className="demo-run-launch-control__copy" role="status" aria-live="polite">
<span className="demo-run-launch-control__eyebrow">Prepared workflow</span>
<strong>{status.label}</strong>
<p>{status.detail}</p>
</div>
<div className="demo-run-launch-control__actions">
<button
type="button"
className="demo-run-launch-control__primary"
onClick={() => void timelineAgent.runPreparedWorkflow(launchLive ? "live" : "replay")}
disabled={isChecking || isRunning || !canLaunch}
>
{launchLabel}
</button>
<button
type="button"
className="demo-run-launch-control__retry"
onClick={retryHealth}
disabled={isChecking}
>
Retry live service
</button>
</div>
</section>
);
};
@@ -3,9 +3,11 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import type { DemoApprovalActions } from "./demo-approval-actions.js";
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
import { findBeat, findScene } from "./storyboard.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
afterEach(() => cleanup());
@@ -53,6 +55,10 @@ const renderBeat = (
options: {
readonly openEvidence?: () => void;
readonly approvalActions?: DemoApprovalActions;
readonly timelineAgent?: TimelineAgentController;
readonly targetStatus?: PresentationTargetHealth;
readonly retryHealth?: () => void;
readonly liveTargetReady?: boolean;
} = {},
) => {
const openEvidence = options.openEvidence ?? vi.fn();
@@ -66,6 +72,10 @@ const renderBeat = (
selectNode={noop}
openEvidence={openEvidence}
approvalActions={options.approvalActions}
timelineAgent={options.timelineAgent}
targetStatus={options.targetStatus}
retryHealth={options.retryHealth}
liveTargetReady={options.liveTargetReady}
/>,
);
return { ...rendered, openEvidence };
@@ -81,6 +91,38 @@ describe("DemoWorkflowScene", () => {
expect(openEvidence).toHaveBeenCalledOnce();
});
it("exposes live launch only on the Scene 10 operation beat", async () => {
const runPreparedWorkflow = vi.fn(async () => {});
const targetStatus: PresentationTargetHealth = {
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
};
const timelineAgent: TimelineAgentController = {
messages: [],
canRun: true,
canRunLive: true,
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
};
const operation = renderBeat("operation", "run-from-deployment", {
timelineAgent,
targetStatus,
liveTargetReady: true,
retryHealth: vi.fn(),
});
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
expect(runPreparedWorkflow).toHaveBeenCalledWith("live");
operation.unmount();
renderBeat("graph");
expect(screen.queryByRole("region", { name: "prepared workflow launch" })).not.toBeInTheDocument();
});
it("keeps the run receipt visible when the graph takes over", () => {
renderBeat("graph");
@@ -21,6 +21,7 @@ import { StageCaption } from "./StageCaption.js";
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
type DemoWorkflowSceneProps = {
readonly scene: SceneDefinition;
@@ -123,6 +124,15 @@ export const DemoWorkflowScene = ({
data-support-surface={surface.supportSurface}
aria-label="demo workflow stage"
>
{beat.id === "operation" && timelineAgent && targetStatus && retryHealth ? (
<DemoRunLaunchControl
status={targetStatus}
liveTargetReady={liveTargetReady ?? false}
demo={demo}
timelineAgent={timelineAgent}
retryHealth={retryHealth}
/>
) : null}
{isGuidedDemoMoment ? (
<GuidedProductMoment
beat={beat}
@@ -88,6 +88,90 @@
font-family: var(--font-mono, monospace);
}
.demo-run-launch-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.7rem;
border: 1px solid color-mix(in oklch, var(--accent-cyan) 32%, var(--stage-line));
border-radius: 0.75rem;
padding: 0.7rem 0.85rem;
background: color-mix(in oklch, var(--stage-surface) 84%, var(--accent-cyan));
}
.demo-run-launch-control__copy {
min-width: 0;
}
.demo-run-launch-control__eyebrow {
display: block;
margin-bottom: 0.15rem;
color: var(--accent-cyan);
font: 650 0.58rem/1 var(--font-mono, monospace);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.demo-run-launch-control__copy strong {
display: block;
color: var(--text-primary);
font-size: 0.9rem;
}
.demo-run-launch-control__copy p {
margin: 0.15rem 0 0;
overflow: hidden;
color: var(--text-muted);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.demo-run-launch-control__actions {
display: flex;
flex: 0 0 auto;
gap: 0.45rem;
}
.demo-run-launch-control__actions button {
border: 1px solid var(--stage-line);
border-radius: 0.5rem;
padding: 0.5rem 0.7rem;
color: var(--text-primary);
font: 650 0.68rem/1 var(--font-mono, monospace);
cursor: pointer;
}
.demo-run-launch-control__primary {
background: var(--accent-cyan);
color: oklch(0.12 0.03 250) !important;
}
.demo-run-launch-control__retry {
background: transparent;
}
.demo-run-launch-control__actions button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
@container presentation-canvas (max-width: 760px) {
.demo-run-launch-control {
align-items: stretch;
flex-direction: column;
}
.demo-run-launch-control__actions {
width: 100%;
}
.demo-run-launch-control__actions button {
flex: 1;
}
}
/* Operation handoff: a single large receipt, not a stack of dashboard cards. */
.presentation-route .operation-block--expanded {
position: relative;