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: "selectWorkflowNode"; readonly nodeId: string }
|
||||||
| { readonly type: "focusOperation"; readonly eventId: string }
|
| { readonly type: "focusOperation"; readonly eventId: string }
|
||||||
| { readonly type: "openEvidence"; readonly eventId: string }
|
| { readonly type: "openEvidence"; readonly eventId: string }
|
||||||
| { readonly type: "showTraceFrame"; readonly frameIndex: number }
|
| { readonly type: "showTraceFrame"; readonly frameIndex: number };
|
||||||
| { readonly type: "setBeat"; readonly beatId: string };
|
|
||||||
|
|
||||||
export type AgentToolCall = {
|
export type AgentToolCall = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export type PresentationToolName =
|
|||||||
| "selectWorkflowNode"
|
| "selectWorkflowNode"
|
||||||
| "focusOperation"
|
| "focusOperation"
|
||||||
| "openEvidence"
|
| "openEvidence"
|
||||||
| "showTraceFrame"
|
| "showTraceFrame";
|
||||||
| "setBeat";
|
|
||||||
|
|
||||||
export type AgentToolName = WorkflowToolName | PresentationToolName;
|
export type AgentToolName = WorkflowToolName | PresentationToolName;
|
||||||
|
|
||||||
@@ -60,11 +59,6 @@ export const AGENT_TOOLS = {
|
|||||||
kind: "presentation",
|
kind: "presentation",
|
||||||
description: "Focus a trace frame in the 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>;
|
} satisfies Record<AgentToolName, AgentToolDescriptor>;
|
||||||
|
|
||||||
export const isAllowedAgentToolName = (name: string): name is AgentToolName =>
|
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 />);
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
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();
|
expect(screen.getByText("Replay · running")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows node spotlight when a graph node is selected", async () => {
|
it("shows node spotlight when a graph node is selected", async () => {
|
||||||
|
window.location.hash = "#scene/workflow-demo/graph";
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
presentationReducer,
|
presentationReducer,
|
||||||
} from "./presentation-state.js";
|
} from "./presentation-state.js";
|
||||||
import { hashForLocation } from "./storyboard-navigation.js";
|
import { hashForLocation } from "./storyboard-navigation.js";
|
||||||
import type { MainLocation, PresentationLocation } from "./storyboard.js";
|
import type { PresentationLocation } from "./storyboard.js";
|
||||||
import "./presentation.css";
|
import "./presentation.css";
|
||||||
|
|
||||||
const projectRecordingToEvidence = (
|
const projectRecordingToEvidence = (
|
||||||
@@ -120,11 +120,6 @@ export const PresentationRoute = () => {
|
|||||||
dispatch({ type: "set_evidence_mode", mode: "open" });
|
dispatch({ type: "set_evidence_mode", mode: "open" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "setBeat": {
|
|
||||||
const loc: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title" };
|
|
||||||
dispatch({ type: "jump", location: loc });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "focusOperation":
|
case "focusOperation":
|
||||||
case "showTraceFrame":
|
case "showTraceFrame":
|
||||||
break;
|
break;
|
||||||
@@ -153,7 +148,6 @@ export const PresentationRoute = () => {
|
|||||||
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
||||||
jump={handleJump}
|
jump={handleJump}
|
||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
clearNode={() => dispatch({ type: "clear_node" })}
|
|
||||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import type { AgentMessage } from "../demo/agent/events.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 { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||||
import { NodeSpotlight } from "./NodeSpotlight.js";
|
|
||||||
import { OperationBlock } from "./OperationBlock.js";
|
|
||||||
import { OperatorChat } from "./OperatorChat.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 { 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 { 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 = {
|
type PresentationStageProps = {
|
||||||
readonly state: PresentationState;
|
readonly state: PresentationState;
|
||||||
@@ -22,19 +18,10 @@ type PresentationStageProps = {
|
|||||||
readonly onDeny?: (() => void) | undefined;
|
readonly onDeny?: (() => void) | undefined;
|
||||||
readonly jump: (location: PresentationLocation) => void;
|
readonly jump: (location: PresentationLocation) => void;
|
||||||
readonly selectNode: (nodeId: string) => void;
|
readonly selectNode: (nodeId: string) => void;
|
||||||
readonly clearNode: () => void;
|
|
||||||
readonly openEvidence: () => void;
|
readonly openEvidence: () => void;
|
||||||
readonly closeOverlay: () => 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 = ({
|
export const PresentationStage = ({
|
||||||
state,
|
state,
|
||||||
demo,
|
demo,
|
||||||
@@ -44,25 +31,10 @@ export const PresentationStage = ({
|
|||||||
onDeny,
|
onDeny,
|
||||||
jump,
|
jump,
|
||||||
selectNode,
|
selectNode,
|
||||||
clearNode,
|
|
||||||
openEvidence,
|
openEvidence,
|
||||||
closeOverlay,
|
closeOverlay,
|
||||||
}: PresentationStageProps) => {
|
}: PresentationStageProps) => {
|
||||||
const composition = compositionForState(state);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -71,25 +43,24 @@ export const PresentationStage = ({
|
|||||||
data-chat-theme={composition.chatTheme}
|
data-chat-theme={composition.chatTheme}
|
||||||
data-chat-mode={composition.chatMode}
|
data-chat-mode={composition.chatMode}
|
||||||
>
|
>
|
||||||
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
|
<aside className="presentation-stage__chat" aria-label="agent chat region">
|
||||||
<section className="presentation-stage__main">
|
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
|
||||||
<header className="presentation-stage__header">
|
</aside>
|
||||||
<StageCaption eyebrow="lda.chat defense" title={scene?.title ?? "Thesis"}>
|
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
||||||
<p>{beat?.caption ?? ""}</p>
|
<SceneBody
|
||||||
</StageCaption>
|
location={state.location}
|
||||||
<button type="button" onClick={openEvidence}>Evidence</button>
|
demo={demo}
|
||||||
</header>
|
selectedNodeId={state.selectedNodeId}
|
||||||
{operationEvent && <OperationBlock event={operationEvent} />}
|
selectNode={selectNode}
|
||||||
<WorkflowGraphStage selectedNodeId={state.selectedNodeId} selectNode={selectNode} />
|
/>
|
||||||
{state.selectedNodeId && (
|
|
||||||
<NodeSpotlight nodeId={state.selectedNodeId} close={clearNode} />
|
|
||||||
)}
|
|
||||||
<p className="presentation-stage__mode">
|
<p className="presentation-stage__mode">
|
||||||
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<BeatRail location={location} jump={jump} />
|
<aside className="presentation-stage__evidence" aria-label="evidence region">
|
||||||
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
|
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
|
||||||
|
</aside>
|
||||||
|
<SceneRail location={state.location} jump={jump} />
|
||||||
</div>
|
</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