feat: connect presentation chat to timeline

This commit is contained in:
lda
2026-07-09 06:19:42 +07:00 Verified
parent 832bedb5ee
commit 0f667061cb
3 changed files with 53 additions and 42 deletions
@@ -25,7 +25,15 @@ beforeAll(() => {
} as unknown as typeof DOMRect;
});
afterEach(() => cleanup());
const setReplayMode = () => {
window.sessionStorage.setItem("lda.workflowConsole.target", "file:///invalid");
};
afterEach(() => {
cleanup();
window.sessionStorage.clear();
window.location.hash = "";
});
describe("PresentationRoute", () => {
it("renders the presentation stage entry point", async () => {
@@ -45,9 +53,10 @@ describe("PresentationRoute", () => {
});
it("renders audience progress chrome without rail or mode label", async () => {
setReplayMode();
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
expect(screen.getByText(/replay fallback is active/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
expect(screen.getByLabelText("scene position")).toBeInTheDocument();
});
@@ -62,6 +71,7 @@ describe("PresentationRoute", () => {
});
it("can advance replay far enough to show a product operation block", async () => {
setReplayMode();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
@@ -104,6 +114,7 @@ describe("PresentationRoute", () => {
});
it("opens the inspector from an explicit operation action", async () => {
setReplayMode();
const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -113,6 +124,7 @@ describe("PresentationRoute", () => {
});
it("closes the inspector from the explicit close action", async () => {
setReplayMode();
const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -124,6 +136,7 @@ describe("PresentationRoute", () => {
});
it("returns to the receipt after closing the inspector on a receipt beat", async () => {
setReplayMode();
const user = userEvent.setup();
window.location.hash = "#scene/interrupt-evidence/trace";
const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -135,6 +148,22 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
});
it("renders a chat run action on the presentation route", async () => {
window.location.hash = "#scene/agent-handoff/request";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /run prepared workflow|run replay walkthrough/i })).toBeInTheDocument();
});
it("uses stored target for live presentation mode", async () => {
window.sessionStorage.setItem("lda.workflowConsole.target", "http://127.0.0.1:8765/rpc");
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /run prepared workflow/i })).toBeInTheDocument();
});
it("opens Scene 10 approval from the canonical hash", async () => {
window.location.hash = "#scene/interrupt-evidence/approval";
const { PresentationRoute } = await import("./PresentationRoute.js");
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import type { EvidenceRecord } from "../app/state.js";
import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedRecipeDriver.js";
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
import { resolvePresentationTarget } from "./live-target.js";
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
import { PresentationCanvas } from "./PresentationCanvas.js";
@@ -47,18 +47,13 @@ export const PresentationRoute = () => {
const recordEvidence = useCallback((record: EvidenceRecord) => {
setEvidence((records) => [...records, record]);
}, []);
const demo = useDemoTimeline(null, recordEvidence, recording);
const agentDriver = useMemo(() => createPreparedRecipeDriver(recording), [recording]);
const agent = useDemoAgent(agentDriver);
const handleApprove = useCallback(() => {
agent.submitApproval({ approved: true, comment: "Approved by operator." });
}, [agent]);
const handleDeny = useCallback(() => {
agent.submitApproval({ approved: false, comment: "Denied by operator." });
}, [agent]);
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
const timelineAgent = useTimelineAgent(
demo,
presentationTarget.mode === "live" ? "live" : "replay",
);
useEffect(() => {
const hash = hashForLocation(state.location);
@@ -96,39 +91,24 @@ export const PresentationRoute = () => {
}, []);
useEffect(() => {
if (demo.state.phase === "ready" && demo.state.mode !== "replay") {
if (demo.state.phase === "ready" && presentationTarget.mode === "replay" && demo.state.mode !== "replay") {
demo.setMode("replay");
}
}, [demo.state.phase, demo.state.mode, demo.setMode]);
if (demo.state.phase === "ready" && presentationTarget.mode === "live" && demo.state.mode !== "live") {
demo.setMode("live");
}
}, [demo.state.phase, demo.state.mode, demo.setMode, presentationTarget.mode]);
useEffect(() => {
if (demo.state.phase === "ready" && demo.state.mode === "replay") {
if (presentationTarget.mode === "replay" && demo.state.phase === "ready" && demo.state.mode === "replay") {
demo.start();
}
}, [demo.state.phase, demo.state.mode, demo.start]);
}, [demo.state.phase, demo.state.mode, demo.start, presentationTarget.mode]);
useEffect(() => {
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
}, [demo.state.mode]);
useEffect(() => {
for (const action of agent.pendingActions) {
switch (action.type) {
case "selectWorkflowNode":
dispatch({ type: "select_node", nodeId: action.nodeId });
break;
case "focusOperation":
case "showTraceFrame":
break;
default:
assertNever(action);
}
}
if (agent.pendingActions.length > 0) {
agent.clearPendingActions();
}
}, [agent.pendingActions, agent.clearPendingActions]);
const handleJump = useCallback(
(location: MainLocation) => dispatch({ type: "jump", location }),
[],
@@ -151,9 +131,7 @@ export const PresentationRoute = () => {
state={state}
demo={demo}
evidence={evidence}
messages={agent.messages}
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
timelineAgent={timelineAgent}
jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
@@ -165,3 +143,4 @@ export const PresentationRoute = () => {
</main>
);
};
@@ -1,6 +1,7 @@
import { domAnimation, LayoutGroup, LazyMotion } from "motion/react";
import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import { SceneBody } from "./SceneBody.js";
import { DiscussionPanel } from "./DiscussionPanel.js";
import { EvidenceInspector } from "./evidence/EvidenceInspector.js";
@@ -16,6 +17,7 @@ type PresentationStageProps = {
readonly demo: DemoTimelineController;
readonly evidence: readonly EvidenceRecord[];
readonly messages?: ReadonlyArray<AgentMessage>;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly jump: (location: MainLocation) => void;
@@ -31,6 +33,7 @@ export const PresentationStage = ({
demo,
evidence,
messages,
timelineAgent,
onApprove,
onDeny,
jump,
@@ -57,7 +60,7 @@ export const PresentationStage = ({
data-scene-view={activeSceneView}
>
<aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
<OperatorChat state={state} messages={messages} timelineAgent={timelineAgent} onApprove={onApprove} onDeny={onDeny} />
</aside>
<section className="presentation-stage__primary" aria-label="primary presentation region">
{state.location.kind === "discussion" ? (
@@ -96,4 +99,4 @@ export const PresentationStage = ({
</LayoutGroup>
</LazyMotion>
);
};
};