feat: compose presentation from storyboard scenes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user