feat: connect presentation chat to timeline
This commit is contained in:
@@ -25,7 +25,15 @@ beforeAll(() => {
|
|||||||
} as unknown as typeof DOMRect;
|
} 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", () => {
|
describe("PresentationRoute", () => {
|
||||||
it("renders the presentation stage entry point", async () => {
|
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 () => {
|
it("renders audience progress chrome without rail or mode label", async () => {
|
||||||
|
setReplayMode();
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
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.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("scene position")).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 () => {
|
it("can advance replay far enough to show a product operation block", async () => {
|
||||||
|
setReplayMode();
|
||||||
window.location.hash = "#scene/workflow-demo/operation";
|
window.location.hash = "#scene/workflow-demo/operation";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
@@ -104,6 +114,7 @@ describe("PresentationRoute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens the inspector from an explicit operation action", async () => {
|
it("opens the inspector from an explicit operation action", async () => {
|
||||||
|
setReplayMode();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
window.location.hash = "#scene/workflow-demo/operation";
|
window.location.hash = "#scene/workflow-demo/operation";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
@@ -113,6 +124,7 @@ describe("PresentationRoute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes the inspector from the explicit close action", async () => {
|
it("closes the inspector from the explicit close action", async () => {
|
||||||
|
setReplayMode();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
window.location.hash = "#scene/workflow-demo/operation";
|
window.location.hash = "#scene/workflow-demo/operation";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
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 () => {
|
it("returns to the receipt after closing the inspector on a receipt beat", async () => {
|
||||||
|
setReplayMode();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
window.location.hash = "#scene/interrupt-evidence/trace";
|
window.location.hash = "#scene/interrupt-evidence/trace";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
@@ -135,6 +148,22 @@ describe("PresentationRoute", () => {
|
|||||||
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
|
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 () => {
|
it("opens Scene 10 approval from the canonical hash", async () => {
|
||||||
window.location.hash = "#scene/interrupt-evidence/approval";
|
window.location.hash = "#scene/interrupt-evidence/approval";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedRecipeDriver.js";
|
import { resolvePresentationTarget } from "./live-target.js";
|
||||||
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
|
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
|
||||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||||
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||||
import { PresentationCanvas } from "./PresentationCanvas.js";
|
import { PresentationCanvas } from "./PresentationCanvas.js";
|
||||||
@@ -47,18 +47,13 @@ export const PresentationRoute = () => {
|
|||||||
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
||||||
setEvidence((records) => [...records, record]);
|
setEvidence((records) => [...records, record]);
|
||||||
}, []);
|
}, []);
|
||||||
const demo = useDemoTimeline(null, recordEvidence, recording);
|
|
||||||
|
|
||||||
const agentDriver = useMemo(() => createPreparedRecipeDriver(recording), [recording]);
|
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||||
const agent = useDemoAgent(agentDriver);
|
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
||||||
|
const timelineAgent = useTimelineAgent(
|
||||||
const handleApprove = useCallback(() => {
|
demo,
|
||||||
agent.submitApproval({ approved: true, comment: "Approved by operator." });
|
presentationTarget.mode === "live" ? "live" : "replay",
|
||||||
}, [agent]);
|
);
|
||||||
|
|
||||||
const handleDeny = useCallback(() => {
|
|
||||||
agent.submitApproval({ approved: false, comment: "Denied by operator." });
|
|
||||||
}, [agent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = hashForLocation(state.location);
|
const hash = hashForLocation(state.location);
|
||||||
@@ -96,39 +91,24 @@ export const PresentationRoute = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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.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(() => {
|
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.start();
|
||||||
}
|
}
|
||||||
}, [demo.state.phase, demo.state.mode, demo.start]);
|
}, [demo.state.phase, demo.state.mode, demo.start, presentationTarget.mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
|
dispatch({ type: "set_playback_mode", mode: demo.state.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(
|
const handleJump = useCallback(
|
||||||
(location: MainLocation) => dispatch({ type: "jump", location }),
|
(location: MainLocation) => dispatch({ type: "jump", location }),
|
||||||
[],
|
[],
|
||||||
@@ -151,9 +131,7 @@ export const PresentationRoute = () => {
|
|||||||
state={state}
|
state={state}
|
||||||
demo={demo}
|
demo={demo}
|
||||||
evidence={evidence}
|
evidence={evidence}
|
||||||
messages={agent.messages}
|
timelineAgent={timelineAgent}
|
||||||
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
|
|
||||||
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
|
||||||
jump={handleJump}
|
jump={handleJump}
|
||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
|
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
|
||||||
@@ -165,3 +143,4 @@ export const PresentationRoute = () => {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { domAnimation, LayoutGroup, LazyMotion } from "motion/react";
|
import { domAnimation, LayoutGroup, LazyMotion } from "motion/react";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import type { AgentMessage } from "../demo/agent/events.js";
|
import type { AgentMessage } from "../demo/agent/events.js";
|
||||||
|
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||||
import { SceneBody } from "./SceneBody.js";
|
import { SceneBody } from "./SceneBody.js";
|
||||||
import { DiscussionPanel } from "./DiscussionPanel.js";
|
import { DiscussionPanel } from "./DiscussionPanel.js";
|
||||||
import { EvidenceInspector } from "./evidence/EvidenceInspector.js";
|
import { EvidenceInspector } from "./evidence/EvidenceInspector.js";
|
||||||
@@ -16,6 +17,7 @@ type PresentationStageProps = {
|
|||||||
readonly demo: DemoTimelineController;
|
readonly demo: DemoTimelineController;
|
||||||
readonly evidence: readonly EvidenceRecord[];
|
readonly evidence: readonly EvidenceRecord[];
|
||||||
readonly messages?: ReadonlyArray<AgentMessage>;
|
readonly messages?: ReadonlyArray<AgentMessage>;
|
||||||
|
readonly timelineAgent?: TimelineAgentController | undefined;
|
||||||
readonly onApprove?: (() => void) | undefined;
|
readonly onApprove?: (() => void) | undefined;
|
||||||
readonly onDeny?: (() => void) | undefined;
|
readonly onDeny?: (() => void) | undefined;
|
||||||
readonly jump: (location: MainLocation) => void;
|
readonly jump: (location: MainLocation) => void;
|
||||||
@@ -31,6 +33,7 @@ export const PresentationStage = ({
|
|||||||
demo,
|
demo,
|
||||||
evidence,
|
evidence,
|
||||||
messages,
|
messages,
|
||||||
|
timelineAgent,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDeny,
|
onDeny,
|
||||||
jump,
|
jump,
|
||||||
@@ -57,7 +60,7 @@ export const PresentationStage = ({
|
|||||||
data-scene-view={activeSceneView}
|
data-scene-view={activeSceneView}
|
||||||
>
|
>
|
||||||
<aside className="presentation-stage__chat" aria-label="agent chat region">
|
<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>
|
</aside>
|
||||||
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
||||||
{state.location.kind === "discussion" ? (
|
{state.location.kind === "discussion" ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user