feat: add presentation beat controller

This commit is contained in:
lda
2026-07-03 05:19:54 +07:00 Verified
parent fb06bde9c1
commit 24bca9df3a
6 changed files with 265 additions and 6 deletions
@@ -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 };
}
};