feat: add presentation beat controller
This commit is contained in:
@@ -9,6 +9,16 @@ describe("PresentationRoute", () => {
|
||||
render(<PresentationRoute />);
|
||||
|
||||
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 = () => (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<p>Planner decisions are separated from deterministic runtime execution.</p>
|
||||
</main>
|
||||
);
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { hashForBeat, presentationBeats } from "./beats.js";
|
||||
import {
|
||||
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