feat: expose Scene 10 live replay launch
This commit is contained in:
@@ -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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.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 type { DemoApprovalActions } from "./demo-approval-actions.js";
|
||||||
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
|
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
|
||||||
import { findBeat, findScene } from "./storyboard.js";
|
import { findBeat, findScene } from "./storyboard.js";
|
||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
@@ -53,6 +55,10 @@ const renderBeat = (
|
|||||||
options: {
|
options: {
|
||||||
readonly openEvidence?: () => void;
|
readonly openEvidence?: () => void;
|
||||||
readonly approvalActions?: DemoApprovalActions;
|
readonly approvalActions?: DemoApprovalActions;
|
||||||
|
readonly timelineAgent?: TimelineAgentController;
|
||||||
|
readonly targetStatus?: PresentationTargetHealth;
|
||||||
|
readonly retryHealth?: () => void;
|
||||||
|
readonly liveTargetReady?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const openEvidence = options.openEvidence ?? vi.fn();
|
const openEvidence = options.openEvidence ?? vi.fn();
|
||||||
@@ -64,8 +70,12 @@ const renderBeat = (
|
|||||||
demo={demo}
|
demo={demo}
|
||||||
selectedNodeId={null}
|
selectedNodeId={null}
|
||||||
selectNode={noop}
|
selectNode={noop}
|
||||||
openEvidence={openEvidence}
|
openEvidence={openEvidence}
|
||||||
approvalActions={options.approvalActions}
|
approvalActions={options.approvalActions}
|
||||||
|
timelineAgent={options.timelineAgent}
|
||||||
|
targetStatus={options.targetStatus}
|
||||||
|
retryHealth={options.retryHealth}
|
||||||
|
liveTargetReady={options.liveTargetReady}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
return { ...rendered, openEvidence };
|
return { ...rendered, openEvidence };
|
||||||
@@ -81,6 +91,38 @@ describe("DemoWorkflowScene", () => {
|
|||||||
expect(openEvidence).toHaveBeenCalledOnce();
|
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", () => {
|
it("keeps the run receipt visible when the graph takes over", () => {
|
||||||
renderBeat("graph");
|
renderBeat("graph");
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { StageCaption } from "./StageCaption.js";
|
|||||||
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
|
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
|
||||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
||||||
|
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
|
||||||
|
|
||||||
type DemoWorkflowSceneProps = {
|
type DemoWorkflowSceneProps = {
|
||||||
readonly scene: SceneDefinition;
|
readonly scene: SceneDefinition;
|
||||||
@@ -123,6 +124,15 @@ export const DemoWorkflowScene = ({
|
|||||||
data-support-surface={surface.supportSurface}
|
data-support-surface={surface.supportSurface}
|
||||||
aria-label="demo workflow stage"
|
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 ? (
|
{isGuidedDemoMoment ? (
|
||||||
<GuidedProductMoment
|
<GuidedProductMoment
|
||||||
beat={beat}
|
beat={beat}
|
||||||
|
|||||||
@@ -88,6 +88,90 @@
|
|||||||
font-family: var(--font-mono, monospace);
|
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. */
|
/* Operation handoff: a single large receipt, not a stack of dashboard cards. */
|
||||||
.presentation-route .operation-block--expanded {
|
.presentation-route .operation-block--expanded {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user