feat: add presentation beat controller
This commit is contained in:
@@ -9,6 +9,16 @@ describe("PresentationRoute", () => {
|
|||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
|
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/planner decisions/i)).toBeInTheDocument();
|
expect(screen.getByText(/External planners propose actions/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts from a hash beat and advances with keyboard", async () => {
|
||||||
|
window.location.hash = "#interrupt-approval";
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
|
||||||
|
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
export const PresentationRoute = () => (
|
import { useEffect, useReducer } from "react";
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
import { hashForBeat, presentationBeats } from "./beats.js";
|
||||||
<p>Planner decisions are separated from deterministic runtime execution.</p>
|
import {
|
||||||
</main>
|
initialPresentationState,
|
||||||
);
|
presentationReducer,
|
||||||
|
} from "./presentation-state.js";
|
||||||
|
|
||||||
|
export const PresentationRoute = () => {
|
||||||
|
const [state, dispatch] = useReducer(
|
||||||
|
presentationReducer,
|
||||||
|
initialPresentationState,
|
||||||
|
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = hashForBeat(state.beat);
|
||||||
|
if (window.location.hash !== hash) {
|
||||||
|
window.history.replaceState(null, "", hash);
|
||||||
|
}
|
||||||
|
}, [state.beat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === " " || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch({ type: "next" });
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch({ type: "previous" });
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
dispatch({ type: "close_overlay" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
|
<p>{beat.caption}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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}`;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
initialPresentationState,
|
||||||
|
presentationReducer,
|
||||||
|
} from "./presentation-state.js";
|
||||||
|
|
||||||
|
describe("presentationReducer", () => {
|
||||||
|
it("advances and rewinds scripted beats without changing playback mode", () => {
|
||||||
|
const advanced = presentationReducer(initialPresentationState, { type: "next" });
|
||||||
|
const rewound = presentationReducer(advanced, { type: "previous" });
|
||||||
|
|
||||||
|
expect(advanced.beat).toBe("chat-request");
|
||||||
|
expect(advanced.playbackMode).toBe("replay");
|
||||||
|
expect(rewound.beat).toBe("intro");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens node detail without changing the current beat", () => {
|
||||||
|
const state = presentationReducer(initialPresentationState, {
|
||||||
|
type: "select_node",
|
||||||
|
nodeId: "review_issues",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.beat).toBe("intro");
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beatFromHash, presentationBeats, type BeatId } from "./beats.js";
|
||||||
|
|
||||||
|
export type PresentationState = {
|
||||||
|
readonly beat: BeatId;
|
||||||
|
readonly selectedNodeId: string | null;
|
||||||
|
readonly chatMode: "full" | "rail" | "hidden";
|
||||||
|
readonly evidenceMode: "hidden" | "peek" | "open";
|
||||||
|
readonly playbackMode: "replay" | "live";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresentationAction =
|
||||||
|
| { readonly type: "next" }
|
||||||
|
| { readonly type: "previous" }
|
||||||
|
| { readonly type: "jump"; readonly beat: BeatId }
|
||||||
|
| { readonly type: "jump_hash"; readonly hash: string }
|
||||||
|
| { readonly type: "select_node"; readonly nodeId: string }
|
||||||
|
| { readonly type: "clear_node" }
|
||||||
|
| { readonly type: "set_evidence_mode"; readonly mode: PresentationState["evidenceMode"] }
|
||||||
|
| { readonly type: "close_overlay" }
|
||||||
|
| { readonly type: "set_playback_mode"; readonly mode: PresentationState["playbackMode"] };
|
||||||
|
|
||||||
|
export const initialPresentationState: PresentationState = {
|
||||||
|
beat: "intro",
|
||||||
|
selectedNodeId: null,
|
||||||
|
chatMode: "full",
|
||||||
|
evidenceMode: "hidden",
|
||||||
|
playbackMode: "replay",
|
||||||
|
};
|
||||||
|
|
||||||
|
const beatIndex = (beat: BeatId): number =>
|
||||||
|
presentationBeats.findIndex((candidate) => candidate.id === beat);
|
||||||
|
|
||||||
|
const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationState => ({
|
||||||
|
...state,
|
||||||
|
beat,
|
||||||
|
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
|
||||||
|
evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const presentationReducer = (
|
||||||
|
state: PresentationState,
|
||||||
|
action: PresentationAction,
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
|
case "previous": {
|
||||||
|
const previous = Math.max(beatIndex(state.beat) - 1, 0);
|
||||||
|
return withDerivedModes(state, presentationBeats[previous]?.id ?? state.beat);
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
if (state.evidenceMode !== "hidden") return { ...state, evidenceMode: "hidden" };
|
||||||
|
if (state.selectedNodeId !== null) return { ...state, selectedNodeId: null };
|
||||||
|
return state;
|
||||||
|
case "set_playback_mode":
|
||||||
|
return { ...state, playbackMode: action.mode };
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user