refactor: navigate presentation by scene and beat
This commit is contained in:
@@ -1,23 +1,30 @@
|
||||
import { presentationBeats, type BeatId } from "./beats.js";
|
||||
import { mainScenes, type PresentationLocation } from "./storyboard.js";
|
||||
|
||||
type BeatRailProps = {
|
||||
readonly activeBeat: BeatId;
|
||||
readonly jump: (beat: BeatId) => void;
|
||||
readonly location: PresentationLocation;
|
||||
readonly jump: (location: PresentationLocation) => void;
|
||||
};
|
||||
|
||||
export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
|
||||
<nav className="beat-rail" aria-label="presentation beat rail">
|
||||
{presentationBeats.map((beat) => (
|
||||
<button
|
||||
key={beat.id}
|
||||
type="button"
|
||||
data-active={beat.id === activeBeat}
|
||||
aria-current={beat.id === activeBeat ? "step" : undefined}
|
||||
onClick={() => jump(beat.id)}
|
||||
>
|
||||
<span>{beat.lifecycleStep}</span>
|
||||
<small>{beat.title}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import { formatJson } from "./format.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import type { EvidenceMode } from "./storyboard.js";
|
||||
|
||||
type EvidenceDrawerProps = {
|
||||
readonly records: readonly EvidenceRecord[];
|
||||
readonly mode: PresentationState["evidenceMode"];
|
||||
readonly mode: EvidenceMode;
|
||||
readonly close: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OperatorChat } from "./OperatorChat.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { initialPresentationState } from "./presentation-state.js";
|
||||
import type { AgentMessage } from "../demo/agent/events.js";
|
||||
|
||||
const state: PresentationState = {
|
||||
beat: "intro",
|
||||
selectedNodeId: null,
|
||||
chatMode: "full",
|
||||
evidenceMode: "hidden",
|
||||
playbackMode: "replay",
|
||||
};
|
||||
|
||||
describe("OperatorChat", () => {
|
||||
it("renders standard agent message parts", () => {
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
@@ -33,7 +25,7 @@ describe("OperatorChat", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<OperatorChat state={state} messages={messages} />);
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
|
||||
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js";
|
||||
import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.js";
|
||||
|
||||
type OperatorChatProps = {
|
||||
readonly state: PresentationState;
|
||||
@@ -78,8 +79,9 @@ const renderPart = (
|
||||
|
||||
export const OperatorChat = ({ state, messages, onApprove, onDeny }: OperatorChatProps) => {
|
||||
const visibleMessages = messages && messages.length > 0 ? messages : fallbackMessages(state);
|
||||
const composition = compositionForState(state);
|
||||
return (
|
||||
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
|
||||
<aside className="operator-chat" data-mode={composition.chatMode} aria-label="scripted operator chat">
|
||||
{visibleMessages.map((message) => (
|
||||
<div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}>
|
||||
<strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong>
|
||||
|
||||
@@ -10,17 +10,17 @@ describe("PresentationRoute", () => {
|
||||
render(<PresentationRoute />);
|
||||
|
||||
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/External planners propose actions/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Thesis/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts from a hash beat and advances with keyboard", async () => {
|
||||
window.location.hash = "#interrupt-approval";
|
||||
it("starts from a scene hash and advances with keyboard", async () => {
|
||||
window.location.hash = "#scene/agent-handoff/request";
|
||||
render(<PresentationRoute />);
|
||||
|
||||
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /Agent Handoff/i })).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/The interface delegates durable work to lda\.chat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders replay-first chat, beat rail, and stage caption", () => {
|
||||
@@ -40,20 +40,16 @@ describe("PresentationRoute", () => {
|
||||
});
|
||||
|
||||
it("can advance replay far enough to show a product operation block", async () => {
|
||||
window.location.hash = "#scene/workflow-demo/operation";
|
||||
render(<PresentationRoute />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /product operation/i }));
|
||||
|
||||
expect(await screen.findByText(/workflow.runs.start/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows resume and trace operation blocks for later beats", async () => {
|
||||
window.location.hash = "#scene/interrupt-evidence/resume";
|
||||
render(<PresentationRoute />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /resume output/i }));
|
||||
expect(await screen.findByText(/workflow.runs.resume/i)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /trace evidence/i }));
|
||||
expect(await screen.findByText(/workflow.runs.trace/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs the prepared agent and applies the interrupt node action", async () => {
|
||||
|
||||
@@ -4,12 +4,13 @@ import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedR
|
||||
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
|
||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||
import { hashForBeat } from "./beats.js";
|
||||
import { PresentationStage } from "./PresentationStage.js";
|
||||
import {
|
||||
initialPresentationState,
|
||||
presentationReducer,
|
||||
} from "./presentation-state.js";
|
||||
import { hashForLocation } from "./storyboard-navigation.js";
|
||||
import type { MainLocation, PresentationLocation } from "./storyboard.js";
|
||||
import "./presentation.css";
|
||||
|
||||
const projectRecordingToEvidence = (
|
||||
@@ -55,11 +56,11 @@ export const PresentationRoute = () => {
|
||||
}, [agent]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = hashForBeat(state.beat);
|
||||
const hash = hashForLocation(state.location);
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.replaceState(null, "", hash);
|
||||
}
|
||||
}, [state.beat]);
|
||||
}, [state.location]);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
@@ -119,9 +120,11 @@ export const PresentationRoute = () => {
|
||||
dispatch({ type: "set_evidence_mode", mode: "open" });
|
||||
break;
|
||||
}
|
||||
case "setBeat":
|
||||
dispatch({ type: "jump_hash", hash: `#${action.beatId}` });
|
||||
case "setBeat": {
|
||||
const loc: MainLocation = { kind: "main", sceneId: "thesis", beatId: "title" };
|
||||
dispatch({ type: "jump", location: loc });
|
||||
break;
|
||||
}
|
||||
case "focusOperation":
|
||||
case "showTraceFrame":
|
||||
break;
|
||||
@@ -134,6 +137,11 @@ export const PresentationRoute = () => {
|
||||
}
|
||||
}, [agent.pendingActions, agent.clearPendingActions, evidence.length, replayEvidence]);
|
||||
|
||||
const handleJump = useCallback(
|
||||
(location: PresentationLocation) => dispatch({ type: "jump", location }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<PresentationStage
|
||||
@@ -143,7 +151,7 @@ export const PresentationRoute = () => {
|
||||
messages={agent.messages}
|
||||
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
|
||||
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
||||
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
|
||||
jump={handleJump}
|
||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||
clearNode={() => dispatch({ type: "clear_node" })}
|
||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import type { AgentMessage } from "../demo/agent/events.js";
|
||||
import { presentationBeats, type BeatId } from "./beats.js";
|
||||
import { BeatRail } from "./BeatRail.js";
|
||||
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||
import { NodeSpotlight } from "./NodeSpotlight.js";
|
||||
@@ -11,6 +10,8 @@ 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";
|
||||
|
||||
type PresentationStageProps = {
|
||||
readonly state: PresentationState;
|
||||
@@ -19,18 +20,19 @@ type PresentationStageProps = {
|
||||
readonly messages?: ReadonlyArray<AgentMessage>;
|
||||
readonly onApprove?: (() => void) | undefined;
|
||||
readonly onDeny?: (() => void) | undefined;
|
||||
readonly jump: (beat: BeatId) => void;
|
||||
readonly jump: (location: PresentationLocation) => void;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly clearNode: () => void;
|
||||
readonly openEvidence: () => void;
|
||||
readonly closeOverlay: () => void;
|
||||
};
|
||||
|
||||
const operationStageByBeat: Partial<Record<BeatId, DemoEventStage>> = {
|
||||
"tool-call-start": "run_start",
|
||||
"interrupt-approval": "interrupt",
|
||||
"resume-output": "run_resume",
|
||||
"trace-evidence": "trace_read",
|
||||
const operationStageByBeat: Readonly<Record<string, DemoEventStage | undefined>> = {
|
||||
operation: "run_start",
|
||||
interrupt: "interrupt",
|
||||
approval: "interrupt",
|
||||
resume: "run_resume",
|
||||
trace: "trace_read",
|
||||
};
|
||||
|
||||
export const PresentationStage = ({
|
||||
@@ -46,20 +48,34 @@ export const PresentationStage = ({
|
||||
openEvidence,
|
||||
closeOverlay,
|
||||
}: PresentationStageProps) => {
|
||||
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
||||
const operationStage = operationStageByBeat[state.beat] ?? null;
|
||||
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 className="presentation-stage" data-beat={state.beat}>
|
||||
<div
|
||||
className="presentation-stage"
|
||||
data-stage-theme={composition.stageTheme}
|
||||
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={beat.title}>
|
||||
<p>{beat.caption}</p>
|
||||
<StageCaption eyebrow="lda.chat defense" title={scene?.title ?? "Thesis"}>
|
||||
<p>{beat?.caption ?? ""}</p>
|
||||
</StageCaption>
|
||||
<button type="button" onClick={openEvidence}>Evidence</button>
|
||||
</header>
|
||||
@@ -72,8 +88,8 @@ export const PresentationStage = ({
|
||||
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
||||
</p>
|
||||
</section>
|
||||
<BeatRail activeBeat={state.beat} jump={jump} />
|
||||
<EvidenceDrawer records={evidence} mode={state.evidenceMode} close={closeOverlay} />
|
||||
<BeatRail location={location} jump={jump} />
|
||||
<EvidenceDrawer records={evidence} mode={composition.evidenceMode} close={closeOverlay} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beatFromHash, hashForBeat, presentationBeats } from "./beats.js";
|
||||
|
||||
describe("presentation beats", () => {
|
||||
it("has stable unique beat ids", () => {
|
||||
const ids = presentationBeats.map((beat) => beat.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids).toEqual([
|
||||
"intro",
|
||||
"chat-request",
|
||||
"tool-call-start",
|
||||
"graph-reveal",
|
||||
"interrupt-approval",
|
||||
"resume-output",
|
||||
"trace-evidence",
|
||||
"boundary-wrap",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps beats to hash fragments and falls back to intro", () => {
|
||||
expect(hashForBeat("interrupt-approval")).toBe("#interrupt-approval");
|
||||
expect(beatFromHash("#interrupt-approval")).toBe("interrupt-approval");
|
||||
expect(beatFromHash("#missing")).toBe("intro");
|
||||
expect(beatFromHash("")).toBe("intro");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
export type BeatId =
|
||||
| "intro"
|
||||
| "chat-request"
|
||||
| "tool-call-start"
|
||||
| "graph-reveal"
|
||||
| "interrupt-approval"
|
||||
| "resume-output"
|
||||
| "trace-evidence"
|
||||
| "boundary-wrap";
|
||||
|
||||
export type PresentationBeat = {
|
||||
readonly id: BeatId;
|
||||
readonly title: string;
|
||||
readonly caption: string;
|
||||
readonly lifecycleStep: string;
|
||||
};
|
||||
|
||||
export const presentationBeats: readonly PresentationBeat[] = [
|
||||
{
|
||||
id: "intro",
|
||||
title: "Planner vs runtime",
|
||||
caption: "External planners propose actions; the workflow runtime owns deterministic execution.",
|
||||
lifecycleStep: "Frame",
|
||||
},
|
||||
{
|
||||
id: "chat-request",
|
||||
title: "Operator request",
|
||||
caption: "The operator asks for a thesis readiness report through a chat-like product surface.",
|
||||
lifecycleStep: "Request",
|
||||
},
|
||||
{
|
||||
id: "tool-call-start",
|
||||
title: "Product operation",
|
||||
caption: "The assistant invokes a prepared workflow operation instead of inventing ad-hoc script state.",
|
||||
lifecycleStep: "Run",
|
||||
},
|
||||
{
|
||||
id: "graph-reveal",
|
||||
title: "Workflow graph",
|
||||
caption: "The graph shows reusable structure, not a one-off tool-calling transcript.",
|
||||
lifecycleStep: "Graph",
|
||||
},
|
||||
{
|
||||
id: "interrupt-approval",
|
||||
title: "Typed interrupt",
|
||||
caption: "Human approval is a typed workflow boundary with explicit resume outcomes.",
|
||||
lifecycleStep: "Interrupt",
|
||||
},
|
||||
{
|
||||
id: "resume-output",
|
||||
title: "Resume output",
|
||||
caption: "Resuming commits the approved branch and produces report and issue-board output.",
|
||||
lifecycleStep: "Resume",
|
||||
},
|
||||
{
|
||||
id: "trace-evidence",
|
||||
title: "Trace evidence",
|
||||
caption: "Run records and trace frames make the execution inspectable after the fact.",
|
||||
lifecycleStep: "Trace",
|
||||
},
|
||||
{
|
||||
id: "boundary-wrap",
|
||||
title: "Boundary",
|
||||
caption: "lda.chat is the workflow substrate that an external or scripted agent can operate.",
|
||||
lifecycleStep: "Boundary",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const beatIds = new Set<BeatId>(presentationBeats.map((beat) => beat.id));
|
||||
|
||||
export const beatFromHash = (hash: string): BeatId => {
|
||||
const id = hash.replace(/^#/, "") as BeatId;
|
||||
return beatIds.has(id) ? id : "intro";
|
||||
};
|
||||
|
||||
export const hashForBeat = (beat: BeatId): string => `#${beat}`;
|
||||
@@ -1,41 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compositionForState,
|
||||
initialPresentationState,
|
||||
presentationReducer,
|
||||
} from "./presentation-state.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
|
||||
describe("presentationReducer", () => {
|
||||
it("advances and rewinds scripted beats without changing playback mode", () => {
|
||||
it("advances within a scene before advancing to the next scene", () => {
|
||||
const advanced = presentationReducer(initialPresentationState, { type: "next" });
|
||||
const rewound = presentationReducer(advanced, { type: "previous" });
|
||||
expect(advanced.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
|
||||
|
||||
expect(advanced.beat).toBe("chat-request");
|
||||
expect(advanced.playbackMode).toBe("replay");
|
||||
expect(rewound.beat).toBe("intro");
|
||||
const advancedAgain = presentationReducer(advanced, { type: "next" });
|
||||
expect(advancedAgain.location).toEqual({ kind: "main", sceneId: "problem", beatId: "direct-actions" });
|
||||
});
|
||||
|
||||
it("opens node detail without changing the current beat", () => {
|
||||
it("rewinds across scene boundaries", () => {
|
||||
const state: MainLocation = { kind: "main", sceneId: "problem", beatId: "direct-actions" };
|
||||
const rewound = presentationReducer(
|
||||
{ ...initialPresentationState, location: state },
|
||||
{ type: "previous" },
|
||||
);
|
||||
expect(rewound.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "substrate" });
|
||||
});
|
||||
|
||||
it("jumps to a specific scene and beat", () => {
|
||||
const jumped = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
|
||||
});
|
||||
expect(jumped.location).toEqual({ kind: "main", sceneId: "workflow-demo", beatId: "graph" });
|
||||
});
|
||||
|
||||
it("parses a scene hash", () => {
|
||||
const state = presentationReducer(initialPresentationState, {
|
||||
type: "jump_hash",
|
||||
hash: "#scene/lifecycle/deployment",
|
||||
});
|
||||
expect(state.location).toEqual({ kind: "main", sceneId: "lifecycle", beatId: "deployment" });
|
||||
});
|
||||
|
||||
it("falls back to default for invalid hash", () => {
|
||||
const state = presentationReducer(initialPresentationState, {
|
||||
type: "jump_hash",
|
||||
hash: "#scene/nope/nope",
|
||||
});
|
||||
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title" });
|
||||
});
|
||||
|
||||
it("opens a discussion branch and returns to the originating beat", () => {
|
||||
const positioned = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
location: { kind: "main", sceneId: "positioning", beatId: "lda-position" },
|
||||
});
|
||||
const opened = presentationReducer(positioned, {
|
||||
type: "open_discussion",
|
||||
branchId: "hosted-automation",
|
||||
});
|
||||
const closed = presentationReducer(opened, { type: "close_discussion" });
|
||||
|
||||
expect(opened.location).toEqual({ kind: "discussion", branchId: "hosted-automation" });
|
||||
expect(closed.location).toEqual(positioned.location);
|
||||
});
|
||||
|
||||
it("opens node detail without changing the current location", () => {
|
||||
const state = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
nodeId: "review_issues",
|
||||
});
|
||||
|
||||
expect(state.beat).toBe("intro");
|
||||
expect(state.location).toEqual(initialPresentationState.location);
|
||||
expect(state.selectedNodeId).toBe("review_issues");
|
||||
});
|
||||
|
||||
it("closes overlays before rewinding content", () => {
|
||||
const opened = presentationReducer(initialPresentationState, {
|
||||
type: "set_evidence_mode",
|
||||
mode: "open",
|
||||
});
|
||||
const closed = presentationReducer(opened, { type: "close_overlay" });
|
||||
|
||||
expect(closed.evidenceMode).toBe("hidden");
|
||||
expect(closed.beat).toBe("intro");
|
||||
});
|
||||
|
||||
it("closes node spotlight before evidence drawer", () => {
|
||||
it("closes overlays in priority order: node, evidence, discussion", () => {
|
||||
const withNode = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
nodeId: "review_issues",
|
||||
@@ -44,12 +81,55 @@ describe("presentationReducer", () => {
|
||||
type: "set_evidence_mode",
|
||||
mode: "open",
|
||||
});
|
||||
const opened = presentationReducer(withEvidence, {
|
||||
type: "open_discussion",
|
||||
branchId: "hosted-automation",
|
||||
});
|
||||
|
||||
expect(withEvidence.selectedNodeId).toBe("review_issues");
|
||||
expect(withEvidence.evidenceMode).toBe("open");
|
||||
const closed1 = presentationReducer(opened, { type: "close_overlay" });
|
||||
expect(closed1.selectedNodeId).toBeNull();
|
||||
expect(closed1.evidenceModeOverride).toBe("open");
|
||||
expect(closed1.location.kind).toBe("discussion");
|
||||
|
||||
const afterEscape = presentationReducer(withEvidence, { type: "close_overlay" });
|
||||
expect(afterEscape.selectedNodeId).toBeNull();
|
||||
expect(afterEscape.evidenceMode).toBe("open");
|
||||
const closed2 = presentationReducer(closed1, { type: "close_overlay" });
|
||||
expect(closed2.evidenceModeOverride).toBeNull();
|
||||
|
||||
const closed3 = presentationReducer(closed2, { type: "close_overlay" });
|
||||
expect(closed3.location.kind).toBe("main");
|
||||
});
|
||||
|
||||
it("does nothing on next while a discussion branch is open", () => {
|
||||
const positioned = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
location: { kind: "main", sceneId: "thesis", beatId: "title" },
|
||||
});
|
||||
const opened = presentationReducer(positioned, {
|
||||
type: "open_discussion",
|
||||
branchId: "direct-orchestration",
|
||||
});
|
||||
const nexted = presentationReducer(opened, { type: "next" });
|
||||
expect(nexted.location).toEqual(opened.location);
|
||||
});
|
||||
|
||||
it("derives act and chat composition from the current beat", () => {
|
||||
const state = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
location: { kind: "main", sceneId: "workflow-demo", beatId: "graph" },
|
||||
});
|
||||
expect(compositionForState(state)).toMatchObject({
|
||||
stageTheme: "night",
|
||||
chatTheme: "light",
|
||||
chatMode: "rail",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes overlays before rewinding content", () => {
|
||||
const opened = presentationReducer(initialPresentationState, {
|
||||
type: "set_evidence_mode",
|
||||
mode: "open",
|
||||
});
|
||||
const closed = presentationReducer(opened, { type: "close_overlay" });
|
||||
expect(closed.evidenceModeOverride).toBeNull();
|
||||
expect(closed.location).toEqual(initialPresentationState.location);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,118 @@
|
||||
import { beatFromHash, presentationBeats, type BeatId } from "./beats.js";
|
||||
import {
|
||||
defaultMainLocation,
|
||||
findDiscussionBranch,
|
||||
findScene,
|
||||
type ChatMode,
|
||||
type ChatTheme,
|
||||
type DiscussionBranchId,
|
||||
type EvidenceMode,
|
||||
type MainLocation,
|
||||
type PresentationLocation,
|
||||
type StageTheme,
|
||||
} from "./storyboard.js";
|
||||
import {
|
||||
hashForLocation,
|
||||
locationFromHash,
|
||||
nextMainLocation,
|
||||
previousMainLocation,
|
||||
} from "./storyboard-navigation.js";
|
||||
|
||||
export type PresentationState = {
|
||||
readonly beat: BeatId;
|
||||
readonly location: PresentationLocation;
|
||||
readonly discussionReturn: MainLocation | null;
|
||||
readonly selectedNodeId: string | null;
|
||||
readonly chatMode: "full" | "rail" | "hidden";
|
||||
readonly evidenceMode: "hidden" | "peek" | "open";
|
||||
readonly evidenceModeOverride: EvidenceMode | null;
|
||||
readonly playbackMode: "replay" | "live";
|
||||
readonly stageThemeOverride: StageTheme | null;
|
||||
readonly chatThemeOverride: ChatTheme | null;
|
||||
readonly chatModeOverride: ChatMode | null;
|
||||
readonly controlsOpen: boolean;
|
||||
};
|
||||
|
||||
export type PresentationAction =
|
||||
| { readonly type: "next" }
|
||||
| { readonly type: "previous" }
|
||||
| { readonly type: "jump"; readonly beat: BeatId }
|
||||
| { readonly type: "jump"; readonly location: PresentationLocation }
|
||||
| { readonly type: "jump_hash"; readonly hash: string }
|
||||
| { readonly type: "open_discussion"; readonly branchId: string }
|
||||
| { readonly type: "close_discussion" }
|
||||
| { readonly type: "select_node"; readonly nodeId: string }
|
||||
| { readonly type: "clear_node" }
|
||||
| { readonly type: "set_evidence_mode"; readonly mode: PresentationState["evidenceMode"] }
|
||||
| { readonly type: "set_evidence_mode"; readonly mode: EvidenceMode }
|
||||
| { readonly type: "close_overlay" }
|
||||
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] };
|
||||
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] }
|
||||
| { readonly type: "set_stage_theme"; readonly theme: StageTheme | null }
|
||||
| { readonly type: "set_chat_theme"; readonly theme: ChatTheme | null }
|
||||
| { readonly type: "set_chat_mode"; readonly mode: ChatMode | null }
|
||||
| { readonly type: "toggle_controls" };
|
||||
|
||||
export const initialPresentationState: PresentationState = {
|
||||
beat: "intro",
|
||||
location: defaultMainLocation,
|
||||
discussionReturn: null,
|
||||
selectedNodeId: null,
|
||||
chatMode: "full",
|
||||
evidenceMode: "hidden",
|
||||
evidenceModeOverride: null,
|
||||
playbackMode: "replay",
|
||||
stageThemeOverride: null,
|
||||
chatThemeOverride: null,
|
||||
chatModeOverride: null,
|
||||
controlsOpen: false,
|
||||
};
|
||||
|
||||
const beatIndex = (beat: BeatId): number =>
|
||||
presentationBeats.findIndex((candidate) => candidate.id === beat);
|
||||
const compositionForLocation = (
|
||||
location: PresentationLocation,
|
||||
evidenceOverride: EvidenceMode | null,
|
||||
stageThemeOverride: StageTheme | null,
|
||||
chatThemeOverride: ChatTheme | null,
|
||||
chatModeOverride: ChatMode | null,
|
||||
): {
|
||||
readonly stageTheme: StageTheme;
|
||||
readonly chatTheme: ChatTheme;
|
||||
readonly chatMode: ChatMode;
|
||||
readonly evidenceMode: EvidenceMode;
|
||||
} => {
|
||||
if (location.kind === "discussion") {
|
||||
const branch = findDiscussionBranch(location.branchId);
|
||||
const parentScene = branch ? findScene(branch.parentSceneId) : findScene("positioning");
|
||||
return {
|
||||
stageTheme: stageThemeOverride ?? parentScene?.stageTheme ?? "paper",
|
||||
chatTheme: chatThemeOverride ?? "dark",
|
||||
chatMode: chatModeOverride ?? "hidden",
|
||||
evidenceMode: evidenceOverride ?? "hidden",
|
||||
};
|
||||
}
|
||||
const scene = findScene(location.sceneId);
|
||||
const beat = scene?.beats.find((b) => b.id === location.beatId);
|
||||
return {
|
||||
stageTheme: stageThemeOverride ?? scene?.stageTheme ?? "paper",
|
||||
chatTheme: chatThemeOverride ?? beat?.chatTheme ?? "dark",
|
||||
chatMode: chatModeOverride ?? beat?.chatMode ?? "hidden",
|
||||
evidenceMode: evidenceOverride ?? beat?.evidenceMode ?? "hidden",
|
||||
};
|
||||
};
|
||||
|
||||
const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationState => ({
|
||||
...state,
|
||||
beat,
|
||||
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
|
||||
evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
|
||||
});
|
||||
export const compositionForState = (state: PresentationState) =>
|
||||
compositionForLocation(
|
||||
state.location,
|
||||
state.evidenceModeOverride,
|
||||
state.stageThemeOverride,
|
||||
state.chatThemeOverride,
|
||||
state.chatModeOverride,
|
||||
);
|
||||
|
||||
const isValidMainLocation = (location: PresentationLocation): location is MainLocation =>
|
||||
location.kind === "main";
|
||||
|
||||
const firstBeatOfScene = (sceneId: string): MainLocation | null => {
|
||||
const scene = findScene(sceneId);
|
||||
if (!scene || scene.beats.length === 0) return null;
|
||||
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId: scene.beats[0]!.id };
|
||||
};
|
||||
|
||||
const clampMainLocation = (location: MainLocation): MainLocation => {
|
||||
const found = findScene(location.sceneId)?.beats.some((b) => b.id === location.beatId);
|
||||
if (found) return location;
|
||||
return defaultMainLocation;
|
||||
};
|
||||
|
||||
export const presentationReducer = (
|
||||
state: PresentationState,
|
||||
@@ -43,28 +120,73 @@ export const presentationReducer = (
|
||||
): PresentationState => {
|
||||
switch (action.type) {
|
||||
case "next": {
|
||||
const next = Math.min(beatIndex(state.beat) + 1, presentationBeats.length - 1);
|
||||
return withDerivedModes(state, presentationBeats[next]?.id ?? state.beat);
|
||||
if (!isValidMainLocation(state.location)) return state;
|
||||
const next = nextMainLocation(state.location);
|
||||
return { ...state, location: next };
|
||||
}
|
||||
case "previous": {
|
||||
const previous = Math.max(beatIndex(state.beat) - 1, 0);
|
||||
return withDerivedModes(state, presentationBeats[previous]?.id ?? state.beat);
|
||||
if (!isValidMainLocation(state.location)) return state;
|
||||
const prev = previousMainLocation(state.location);
|
||||
return { ...state, location: prev };
|
||||
}
|
||||
case "jump": {
|
||||
if (state.location.kind === "discussion") return state;
|
||||
return { ...state, location: action.location };
|
||||
}
|
||||
case "jump_hash": {
|
||||
const parsed = locationFromHash(action.hash);
|
||||
if (parsed.kind === "main") {
|
||||
return { ...state, location: clampMainLocation(parsed) };
|
||||
}
|
||||
return { ...state, location: parsed };
|
||||
}
|
||||
case "open_discussion": {
|
||||
const branch = findDiscussionBranch(action.branchId);
|
||||
if (!branch) return state;
|
||||
const returnLocation = isValidMainLocation(state.location)
|
||||
? state.location
|
||||
: firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation;
|
||||
return {
|
||||
...state,
|
||||
location: { kind: "discussion", branchId: action.branchId as DiscussionBranchId },
|
||||
discussionReturn: returnLocation,
|
||||
};
|
||||
}
|
||||
case "close_discussion": {
|
||||
if (state.location.kind !== "discussion") return state;
|
||||
return {
|
||||
...state,
|
||||
location: state.discussionReturn ?? defaultMainLocation,
|
||||
discussionReturn: null,
|
||||
};
|
||||
}
|
||||
case "jump":
|
||||
return withDerivedModes(state, action.beat);
|
||||
case "jump_hash":
|
||||
return withDerivedModes(state, beatFromHash(action.hash));
|
||||
case "select_node":
|
||||
return { ...state, selectedNodeId: action.nodeId };
|
||||
case "clear_node":
|
||||
return { ...state, selectedNodeId: null };
|
||||
case "set_evidence_mode":
|
||||
return { ...state, evidenceMode: action.mode };
|
||||
case "close_overlay":
|
||||
return { ...state, evidenceModeOverride: action.mode };
|
||||
case "close_overlay": {
|
||||
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
|
||||
if (state.evidenceMode !== "hidden") return { ...state, evidenceMode: "hidden" };
|
||||
if (state.evidenceModeOverride !== null) return { ...state, evidenceModeOverride: null };
|
||||
if (state.location.kind === "discussion") {
|
||||
return {
|
||||
...state,
|
||||
location: state.discussionReturn ?? defaultMainLocation,
|
||||
discussionReturn: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case "set_playback_mode":
|
||||
return { ...state, playbackMode: action.mode };
|
||||
case "set_stage_theme":
|
||||
return { ...state, stageThemeOverride: action.theme };
|
||||
case "set_chat_theme":
|
||||
return { ...state, chatThemeOverride: action.theme };
|
||||
case "set_chat_mode":
|
||||
return { ...state, chatModeOverride: action.mode };
|
||||
case "toggle_controls":
|
||||
return { ...state, controlsOpen: !state.controlsOpen };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hashForLocation,
|
||||
locationFromHash,
|
||||
nextMainLocation,
|
||||
previousMainLocation,
|
||||
} from "./storyboard-navigation.js";
|
||||
import { defaultMainLocation, type MainLocation } from "./storyboard.js";
|
||||
|
||||
describe("storyboard navigation", () => {
|
||||
it("round-trips main and discussion hashes", () => {
|
||||
const main: MainLocation = { kind: "main", sceneId: "lifecycle", beatId: "deployment" };
|
||||
expect(locationFromHash(hashForLocation(main))).toEqual(main);
|
||||
expect(locationFromHash("#discuss/hosted-automation")).toEqual({
|
||||
kind: "discussion",
|
||||
branchId: "hosted-automation",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back for unknown scene, beat, and branch hashes", () => {
|
||||
expect(locationFromHash("#scene/missing/nope")).toEqual(defaultMainLocation);
|
||||
expect(locationFromHash("#scene/lifecycle/nope")).toEqual(defaultMainLocation);
|
||||
expect(locationFromHash("#discuss/nope")).toEqual(defaultMainLocation);
|
||||
});
|
||||
|
||||
it("advances within a scene before advancing to the next scene", () => {
|
||||
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "title" })).toEqual({
|
||||
kind: "main",
|
||||
sceneId: "thesis",
|
||||
beatId: "substrate",
|
||||
});
|
||||
expect(nextMainLocation({ kind: "main", sceneId: "thesis", beatId: "substrate" })).toEqual({
|
||||
kind: "main",
|
||||
sceneId: "problem",
|
||||
beatId: "direct-actions",
|
||||
});
|
||||
});
|
||||
|
||||
it("rewinds across scene boundaries", () => {
|
||||
expect(previousMainLocation({ kind: "main", sceneId: "problem", beatId: "direct-actions" })).toEqual({
|
||||
kind: "main",
|
||||
sceneId: "thesis",
|
||||
beatId: "substrate",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defaultMainLocation,
|
||||
findDiscussionBranch,
|
||||
findScene,
|
||||
mainScenes,
|
||||
type DiscussionLocation,
|
||||
type MainLocation,
|
||||
type PresentationLocation,
|
||||
} from "./storyboard.js";
|
||||
|
||||
const flattenMainLocations = (): readonly MainLocation[] =>
|
||||
mainScenes.flatMap((scene) =>
|
||||
scene.beats.map((beat) => ({ kind: "main" as const, sceneId: scene.id, beatId: beat.id })),
|
||||
);
|
||||
|
||||
export const hashForLocation = (location: PresentationLocation): string =>
|
||||
location.kind === "main"
|
||||
? `#scene/${encodeURIComponent(location.sceneId)}/${encodeURIComponent(location.beatId)}`
|
||||
: `#discuss/${encodeURIComponent(location.branchId)}`;
|
||||
|
||||
export const locationFromHash = (hash: string): PresentationLocation => {
|
||||
const raw = hash.replace(/^#/, "");
|
||||
const sceneMatch = raw.match(/^scene\/([^/]+)\/(.+)$/);
|
||||
if (sceneMatch) {
|
||||
const sceneId = decodeURIComponent(sceneMatch[1]!);
|
||||
const beatId = decodeURIComponent(sceneMatch[2]!);
|
||||
const scene = findScene(sceneId);
|
||||
if (scene && scene.beats.some((b) => b.id === beatId)) {
|
||||
return { kind: "main", sceneId: scene.id as MainLocation["sceneId"], beatId };
|
||||
}
|
||||
return defaultMainLocation;
|
||||
}
|
||||
const discussMatch = raw.match(/^discuss\/(.+)$/);
|
||||
if (discussMatch) {
|
||||
const branchId = decodeURIComponent(discussMatch[1]!);
|
||||
if (findDiscussionBranch(branchId)) {
|
||||
return { kind: "discussion", branchId: branchId as DiscussionLocation["branchId"] };
|
||||
}
|
||||
return defaultMainLocation;
|
||||
}
|
||||
return defaultMainLocation;
|
||||
};
|
||||
|
||||
export const nextMainLocation = (current: MainLocation): MainLocation => {
|
||||
const locations = flattenMainLocations();
|
||||
const index = locations.findIndex(
|
||||
(loc) => loc.sceneId === current.sceneId && loc.beatId === current.beatId,
|
||||
);
|
||||
if (index === -1 || index === locations.length - 1) return current;
|
||||
return locations[index + 1]!;
|
||||
};
|
||||
|
||||
export const previousMainLocation = (current: MainLocation): MainLocation => {
|
||||
const locations = flattenMainLocations();
|
||||
const index = locations.findIndex(
|
||||
(loc) => loc.sceneId === current.sceneId && loc.beatId === current.beatId,
|
||||
);
|
||||
if (index <= 0) return current;
|
||||
return locations[index - 1]!;
|
||||
};
|
||||
Reference in New Issue
Block a user