refactor: move prepared run controls out of scene content

This commit is contained in:
lda
2026-07-12 15:38:20 +07:00 Verified
parent a074a24d45
commit ed2ee043bd
8 changed files with 15 additions and 391 deletions
@@ -1,156 +0,0 @@
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),
});
expect(screen.getByText("Live target ready")).toBeInTheDocument();
expect(screen.getByText("Direct view is replay; launch starts live operations.")).toBeInTheDocument();
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("does not offer retry when no live target is configured", () => {
renderControl({ status: replayStatus, liveTargetReady: false });
expect(screen.getByRole("button", { name: "Play replay walkthrough" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry live service" })).not.toBeInTheDocument();
});
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();
});
});
@@ -1,73 +0,0 @@
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;
const canRetryLive = status.kind !== "replay" || liveTargetReady;
const statusLabel = liveTargetReady ? "Live target ready" : status.label;
const statusDetail = liveTargetReady && status.kind === "replay"
? "Direct view is replay; launch starts live operations."
: status.detail;
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>{statusLabel}</strong>
<p>{statusDetail}</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>
{canRetryLive ? (
<button
type="button"
className="demo-run-launch-control__retry"
onClick={retryHealth}
disabled={isChecking}
>
Retry live service
</button>
) : null}
</div>
</section>
);
};
@@ -3,11 +3,9 @@ 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());
@@ -55,10 +53,6 @@ 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();
@@ -72,10 +66,6 @@ const renderBeat = (
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 };
@@ -91,36 +81,11 @@ describe("DemoWorkflowScene", () => {
expect(openEvidence).toHaveBeenCalledOnce(); expect(openEvidence).toHaveBeenCalledOnce();
}); });
it("exposes live launch only on the Scene 10 operation beat", async () => { it("keeps run controls out of the operation beat", () => {
const runPreparedWorkflow = vi.fn(async () => {}); renderBeat("operation");
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(); expect(screen.queryByRole("region", { name: "prepared workflow launch" })).not.toBeInTheDocument();
expect(screen.queryByText("Retry live service")).not.toBeInTheDocument();
}); });
it("keeps the run receipt visible when the graph takes over", () => { it("keeps the run receipt visible when the graph takes over", () => {
@@ -1,6 +1,5 @@
import type { DemoEvent } from "../demo/timeline/models.js"; import type { DemoEvent } from "../demo/timeline/models.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js"; import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import { import {
demoBeatLensForBeat, demoBeatLensForBeat,
graphExecutionForBeat, graphExecutionForBeat,
@@ -19,9 +18,7 @@ import { OperationBlock } from "./OperationBlock.js";
import { RunInputFileBrowser } from "./RunInputFileBrowser.js"; import { RunInputFileBrowser } from "./RunInputFileBrowser.js";
import { StageCaption } from "./StageCaption.js"; 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 { WorkflowGraphStage } from "./WorkflowGraphStage.js"; import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
type DemoWorkflowSceneProps = { type DemoWorkflowSceneProps = {
readonly scene: SceneDefinition; readonly scene: SceneDefinition;
@@ -31,10 +28,6 @@ type DemoWorkflowSceneProps = {
readonly selectNode: (nodeId: string | null) => void; readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void; readonly openEvidence: () => void;
readonly approvalActions?: DemoApprovalActions | undefined; readonly approvalActions?: DemoApprovalActions | undefined;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly targetStatus?: PresentationTargetHealth | undefined;
readonly retryHealth?: (() => void) | undefined;
readonly liveTargetReady?: boolean | undefined;
}; };
type DemoWorkflowLayout = "operation" | "graph" | "interrupt" | "approval" | "evidence"; type DemoWorkflowLayout = "operation" | "graph" | "interrupt" | "approval" | "evidence";
@@ -66,10 +59,6 @@ export const DemoWorkflowScene = ({
selectNode, selectNode,
openEvidence, openEvidence,
approvalActions, approvalActions,
timelineAgent,
targetStatus,
retryHealth,
liveTargetReady,
}: DemoWorkflowSceneProps) => { }: DemoWorkflowSceneProps) => {
const runStart = findEvent(demo, "run_start"); const runStart = findEvent(demo, "run_start");
const runResume = findEvent(demo, "run_resume"); const runResume = findEvent(demo, "run_resume");
@@ -122,15 +111,6 @@ 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}
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { callOperation } from "../connection/api.js"; import { callOperation } from "../connection/api.js";
@@ -286,19 +286,19 @@ describe("PresentationRoute", () => {
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(3); expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(2);
}); });
it("exposes an explicit live launch on the Scene 10 operation beat", async () => { it("owns the live run action in the footer rail", async () => {
window.sessionStorage.setItem("lda.workflowConsole.target", "http://127.0.0.1:8765/rpc"); window.sessionStorage.setItem("lda.workflowConsole.target", "http://127.0.0.1:8765/rpc");
window.location.hash = "#scene/run-from-deployment/operation"; window.location.hash = "#scene/run-from-deployment/operation";
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
const launches = await screen.findAllByRole("button", { name: "Run prepared workflow" }); const footer = await screen.findByRole("contentinfo", { name: /presentation footer/i });
const launch = launches.at(-1); const runButton = within(footer).getByRole("button", { name: "Run prepared workflow" });
if (!launch) throw new Error("Expected a live workflow launch control"); expect(screen.queryByRole("region", { name: "prepared workflow launch" })).not.toBeInTheDocument();
await userEvent.click(launch); await userEvent.click(runButton);
await waitFor(() => { await waitFor(() => {
expect(mockedCallOperation.mock.calls.some(([operation]) => operation === "workflow.deployments.inspect")) expect(mockedCallOperation.mock.calls.some(([operation]) => operation === "workflow.deployments.inspect"))
@@ -406,7 +406,10 @@ describe("PresentationRoute", () => {
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
const runButton = await screen.findByRole("button", { name: /run replay walkthrough/i }); const footer = await screen.findByRole("contentinfo", { name: /presentation footer/i });
const runButton = within(footer).getByRole("button", { name: /play replay walkthrough/i });
expect(screen.queryByRole("region", { name: "prepared workflow launch" })).not.toBeInTheDocument();
expect(screen.queryByText("Retry live service")).not.toBeInTheDocument();
await user.click(runButton); await user.click(runButton);
expect(await screen.findByLabelText("workflow.runs.start operation")).toBeInTheDocument(); expect(await screen.findByLabelText("workflow.runs.start operation")).toBeInTheDocument();
}); });
@@ -108,9 +108,6 @@ export const PresentationStage = ({
}} }}
motionDisabled={state.motionDisabled} motionDisabled={state.motionDisabled}
approvalActions={approvalActions} approvalActions={approvalActions}
targetStatus={targetStatus}
retryHealth={retryHealth}
liveTargetReady={liveTargetReady}
onScene9Advance={onScene9Advance} onScene9Advance={onScene9Advance}
/> />
)} )}
@@ -22,7 +22,6 @@ import { projectPreparedAuthoringPhase } from "./authoring/authoring-projection.
import type { AuthoringPhaseId } from "./authoring/authoring-recording.js"; import type { AuthoringPhaseId } from "./authoring/authoring-recording.js";
import { OpeningThesisScene } from "./opening/OpeningThesisScene.js"; import { OpeningThesisScene } from "./opening/OpeningThesisScene.js";
import { ProblemLoopScene } from "./opening/ProblemLoopScene.js"; import { ProblemLoopScene } from "./opening/ProblemLoopScene.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
type SceneBodyProps = { type SceneBodyProps = {
readonly location: PresentationLocation; readonly location: PresentationLocation;
@@ -35,9 +34,6 @@ type SceneBodyProps = {
readonly onFocusPathChange: (path: readonly string[]) => void; readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean; readonly motionDisabled: boolean;
readonly approvalActions?: DemoApprovalActions | undefined; readonly approvalActions?: DemoApprovalActions | undefined;
readonly targetStatus?: PresentationTargetHealth | undefined;
readonly retryHealth?: (() => void) | undefined;
readonly liveTargetReady?: boolean | undefined;
readonly onScene9Advance?: (() => void) | undefined; readonly onScene9Advance?: (() => void) | undefined;
}; };
@@ -316,7 +312,7 @@ const assertNever = (value: never): never => {
throw new Error(`Unexpected view: ${value}`); throw new Error(`Unexpected view: ${value}`);
}; };
export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selectNode, openEvidence, openDiscussion, onFocusPathChange, motionDisabled, approvalActions, targetStatus, retryHealth, liveTargetReady, onScene9Advance }: SceneBodyProps) => { export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvidence, openDiscussion, onFocusPathChange, motionDisabled, approvalActions, onScene9Advance }: SceneBodyProps) => {
const sceneId = location.kind === "main" ? location.sceneId : "positioning"; const sceneId = location.kind === "main" ? location.sceneId : "positioning";
const beatId = location.kind === "main" ? location.beatId : "landscape"; const beatId = location.kind === "main" ? location.beatId : "landscape";
const scene = findScene(sceneId) ?? findScene("thesis")!; const scene = findScene(sceneId) ?? findScene("thesis")!;
@@ -368,10 +364,6 @@ export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selec
selectNode={selectNode} selectNode={selectNode}
openEvidence={openEvidence} openEvidence={openEvidence}
approvalActions={approvalActions} approvalActions={approvalActions}
timelineAgent={timelineAgent}
targetStatus={targetStatus}
retryHealth={retryHealth}
liveTargetReady={liveTargetReady}
/> />
); );
case "evaluation": case "evaluation":
@@ -88,90 +88,6 @@
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;