feat: compose presentation from storyboard scenes

This commit is contained in:
lda
2026-07-04 16:47:12 +07:00 Verified
parent 5947ee625c
commit 7073b65dec
9 changed files with 206 additions and 94 deletions
+1 -2
View File
@@ -6,8 +6,7 @@ export type PresentationToolAction =
| { readonly type: "selectWorkflowNode"; readonly nodeId: string }
| { readonly type: "focusOperation"; readonly eventId: string }
| { readonly type: "openEvidence"; readonly eventId: string }
| { readonly type: "showTraceFrame"; readonly frameIndex: number }
| { readonly type: "setBeat"; readonly beatId: string };
| { readonly type: "showTraceFrame"; readonly frameIndex: number };
export type AgentToolCall = {
readonly id: string;
+1 -7
View File
@@ -8,8 +8,7 @@ export type PresentationToolName =
| "selectWorkflowNode"
| "focusOperation"
| "openEvidence"
| "showTraceFrame"
| "setBeat";
| "showTraceFrame";
export type AgentToolName = WorkflowToolName | PresentationToolName;
@@ -60,11 +59,6 @@ export const AGENT_TOOLS = {
kind: "presentation",
description: "Focus a trace frame in the presentation.",
},
setBeat: {
name: "setBeat",
kind: "presentation",
description: "Move the presentation to a named beat.",
},
} satisfies Record<AgentToolName, AgentToolDescriptor>;
export const isAllowedAgentToolName = (name: string): name is AgentToolName =>
@@ -1,30 +0,0 @@
import { mainScenes, type PresentationLocation } from "./storyboard.js";
type BeatRailProps = {
readonly location: PresentationLocation;
readonly jump: (location: PresentationLocation) => void;
};
export const BeatRail = ({ location, jump }: BeatRailProps) => {
const activeSceneId = location.kind === "main" ? location.sceneId : "positioning";
return (
<nav className="beat-rail" aria-label="presentation beat rail">
{mainScenes.map((scene) => {
const firstBeat = scene.beats[0]!;
const isActive = scene.id === activeSceneId;
return (
<button
key={scene.id}
type="button"
data-active={isActive}
aria-current={isActive ? "step" : undefined}
onClick={() => jump({ kind: "main", sceneId: scene.id, beatId: firstBeat.id })}
>
<span>{scene.number}</span>
<small>{scene.title}</small>
</button>
);
})}
</nav>
);
};
@@ -27,11 +27,12 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />);
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument();
expect(screen.getByLabelText(/presentation scene rail/i)).toBeInTheDocument();
expect(screen.getByText("Replay · running")).toBeInTheDocument();
});
it("shows node spotlight when a graph node is selected", async () => {
window.location.hash = "#scene/workflow-demo/graph";
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
@@ -10,7 +10,7 @@ import {
presentationReducer,
} from "./presentation-state.js";
import { hashForLocation } from "./storyboard-navigation.js";
import type { MainLocation, PresentationLocation } from "./storyboard.js";
import type { PresentationLocation } from "./storyboard.js";
import "./presentation.css";
const projectRecordingToEvidence = (
@@ -120,11 +120,6 @@ export const PresentationRoute = () => {
dispatch({ type: "set_evidence_mode", mode: "open" });
break;
}
case "setBeat": {
const loc: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title" };
dispatch({ type: "jump", location: loc });
break;
}
case "focusOperation":
case "showTraceFrame":
break;
@@ -153,7 +148,6 @@ export const PresentationRoute = () => {
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
clearNode={() => dispatch({ type: "clear_node" })}
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
closeOverlay={() => dispatch({ type: "close_overlay" })}
/>
@@ -1,17 +1,13 @@
import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import { BeatRail } from "./BeatRail.js";
import { SceneBody } from "./SceneBody.js";
import { SceneRail } from "./SceneRail.js";
import { EvidenceDrawer } from "./EvidenceDrawer.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
import { OperatorChat } from "./OperatorChat.js";
import { StageCaption } from "./StageCaption.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import type { PresentationState } from "./presentation-state.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { DemoEventStage } from "../demo/timeline/models.js";
import { compositionForState } from "./presentation-state.js";
import { findBeat, findScene, type MainLocation, type PresentationLocation } from "./storyboard.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { MainLocation, PresentationLocation } from "./storyboard.js";
type PresentationStageProps = {
readonly state: PresentationState;
@@ -22,19 +18,10 @@ type PresentationStageProps = {
readonly onDeny?: (() => void) | undefined;
readonly jump: (location: PresentationLocation) => void;
readonly selectNode: (nodeId: string) => void;
readonly clearNode: () => void;
readonly openEvidence: () => void;
readonly closeOverlay: () => void;
};
const operationStageByBeat: Readonly<Record<string, DemoEventStage | undefined>> = {
operation: "run_start",
interrupt: "interrupt",
approval: "interrupt",
resume: "run_resume",
trace: "trace_read",
};
export const PresentationStage = ({
state,
demo,
@@ -44,25 +31,10 @@ export const PresentationStage = ({
onDeny,
jump,
selectNode,
clearNode,
openEvidence,
closeOverlay,
}: PresentationStageProps) => {
const composition = compositionForState(state);
const location = state.location;
const scene =
location.kind === "main" ? findScene(location.sceneId) : findScene("positioning");
const beat =
location.kind === "main" && scene
? scene.beats.find((b) => b.id === location.beatId)
: scene?.beats[0];
const operationStage = location.kind === "main" ? operationStageByBeat[location.beatId] ?? null : null;
const operationEvent = operationStage
? demo.state.events.find((event) => event.stage === operationStage) ?? null
: null;
return (
<div
@@ -71,25 +43,24 @@ export const PresentationStage = ({
data-chat-theme={composition.chatTheme}
data-chat-mode={composition.chatMode}
>
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
<section className="presentation-stage__main">
<header className="presentation-stage__header">
<StageCaption eyebrow="lda.chat defense" title={scene?.title ?? "Thesis"}>
<p>{beat?.caption ?? ""}</p>
</StageCaption>
<button type="button" onClick={openEvidence}>Evidence</button>
</header>
{operationEvent && <OperationBlock event={operationEvent} />}
<WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
{state.selectedNodeId && (
<NodeSpotlight nodeId={state.selectedNodeId} close={clearNode} />
)}
<aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
</aside>
<section className="presentation-stage__primary" aria-label="primary presentation region">
<SceneBody
location={state.location}
demo={demo}
selectedNodeId={state.selectedNodeId}
selectNode={selectNode}
/>
<p className="presentation-stage__mode">
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
</p>
</section>
<BeatRail location={location} jump={jump} />
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
<aside className="presentation-stage__evidence" aria-label="evidence region">
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
</aside>
<SceneRail location={state.location} jump={jump} />
</div>
);
};
@@ -0,0 +1,37 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { SceneBody } from "./SceneBody.js";
import type { PresentationLocation } from "./storyboard.js";
const noop = () => {};
afterEach(() => cleanup());
describe("SceneBody", () => {
it("renders narrative metadata without mounting the demo graph", () => {
const location: PresentationLocation = { kind: "main", sceneId: "positioning", beatId: "landscape" };
render(
<SceneBody
location={location}
demo={null as never}
selectedNodeId={null}
selectNode={noop}
/>,
);
expect(screen.getByRole("heading", { name: /Positioning and Related Systems/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/workflow graph/i)).not.toBeInTheDocument();
});
it("renders the real workflow graph for demo scenes", () => {
const location: PresentationLocation = { kind: "main", sceneId: "workflow-demo", beatId: "graph" };
render(
<SceneBody
location={location}
demo={null as never}
selectedNodeId={null}
selectNode={noop}
/>,
);
expect(screen.getByLabelText(/workflow graph/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,106 @@
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { findBeat, findScene, type PresentationLocation, type SceneDefinition, type SceneBeatDefinition } from "./storyboard.js";
import { NodeSpotlight } from "./NodeSpotlight.js";
import { OperationBlock } from "./OperationBlock.js";
import { StageCaption } from "./StageCaption.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
type SceneBodyProps = {
readonly location: PresentationLocation;
readonly demo: DemoTimelineController;
readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void;
};
const operationStageByBeat: Readonly<Record<string, string | undefined>> = {
operation: "run_start",
interrupt: "interrupt",
approval: "interrupt",
resume: "run_resume",
trace: "trace_read",
};
const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow={`Act ${scene.stageTheme === "paper" ? "I" : "II"} · ${scene.claimClass}`} title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const AgentHandoffScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<>
<StageCaption eyebrow="Agent handoff" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
<p className="scene-body__evidence">{scene.evidencePointer}</p>
</>
);
const DemoWorkflowScene = ({
scene,
beat,
demo,
selectedNodeId,
selectNode,
}: {
scene: SceneDefinition;
beat: SceneBeatDefinition;
demo: DemoTimelineController;
selectedNodeId: string | null;
selectNode: (nodeId: string) => void;
}) => {
const operationStage = operationStageByBeat[beat.id] ?? null;
const operationEvent = operationStage
? demo.state.events.find((event) => event.stage === operationStage) ?? null
: null;
return (
<>
<StageCaption eyebrow="Demo" title={scene.title}>
<p>{beat.caption}</p>
</StageCaption>
{operationEvent && <OperationBlock event={operationEvent} />}
<WorkflowGraphStage selectedNodeId={selectedNodeId} selectNode={selectNode} />
{selectedNodeId && <NodeSpotlight nodeId={selectedNodeId} close={() => selectNode("")} />}
</>
);
};
const assertNever = (value: never): never => {
throw new Error(`Unexpected view: ${value}`);
};
export const SceneBody = ({ location, demo, selectedNodeId, selectNode }: SceneBodyProps) => {
const sceneId = location.kind === "main" ? location.sceneId : "positioning";
const beatId = location.kind === "main" ? location.beatId : "landscape";
const scene = findScene(sceneId) ?? findScene("thesis")!;
const beat = findBeat(sceneId, beatId) ?? scene.beats[0]!;
switch (scene.view) {
case "narrative":
case "positioning":
case "boundary":
case "lifecycle":
case "architecture":
case "authoring":
case "evaluation":
case "conclusion":
return <NarrativeScene scene={scene} beat={beat} />;
case "agent":
return <AgentHandoffScene scene={scene} beat={beat} />;
case "demo":
return (
<DemoWorkflowScene
scene={scene}
beat={beat}
demo={demo}
selectedNodeId={selectedNodeId}
selectNode={selectNode}
/>
);
default:
return assertNever(scene.view);
}
};
@@ -0,0 +1,40 @@
import { mainScenes, type MainLocation, type PresentationLocation } from "./storyboard.js";
type SceneRailProps = {
readonly location: PresentationLocation;
readonly jump: (location: MainLocation) => void;
};
export const SceneRail = ({ location, jump }: SceneRailProps) => {
const activeSceneId = location.kind === "main" ? location.sceneId : "positioning";
return (
<nav className="scene-rail" aria-label="presentation scene rail">
{mainScenes.map((scene) => {
const isActive = scene.id === activeSceneId;
const activeBeatId =
isActive && location.kind === "main" ? location.beatId : scene.beats[0]!.id;
return (
<button
key={scene.id}
type="button"
data-active={isActive}
aria-current={isActive ? "step" : undefined}
onClick={() => jump({ kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: scene.beats[0]!.id })}
className="scene-rail__scene"
>
<span className="scene-rail__number">{scene.number}</span>
<small className="scene-rail__title">{scene.title}</small>
<span className="scene-rail__progress">
{scene.beats.map((beat) => (
<span
key={beat.id}
className={`scene-rail__beat ${beat.id === activeBeatId ? "scene-rail__beat--active" : ""}`}
/>
))}
</span>
</button>
);
})}
</nav>
);
};