feat: synchronize audience presentation route

This commit is contained in:
lda
2026-07-14 14:39:25 +07:00 Verified
parent d0f9e0ee3c
commit ae0e1bee36
6 changed files with 274 additions and 5 deletions
@@ -2,10 +2,20 @@ import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import type { DemoChromePresentation } from "./presentation-demo-chrome.js"; import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
import { PresentationFooter } from "./PresentationFooter.js"; import { PresentationFooter } from "./PresentationFooter.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
const standaloneSyncController = (): PresentationSyncController => ({
state: { kind: "standalone" },
startSession: vi.fn(async () => {}),
joinSession: vi.fn(async () => {}),
retry: vi.fn(),
leaveSession: vi.fn(),
endSession: vi.fn(),
});
describe("PresentationFooter", () => { describe("PresentationFooter", () => {
it("combines scene progress and evidence provenance", () => { it("combines scene progress and evidence provenance", () => {
const evidence: EvidenceRecord = { const evidence: EvidenceRecord = {
@@ -30,6 +40,7 @@ describe("PresentationFooter", () => {
}} }}
evidence={[evidence]} evidence={[evidence]}
demoRail={hiddenRail} demoRail={hiddenRail}
syncController={standaloneSyncController()}
retryHealth={vi.fn()} retryHealth={vi.fn()}
showEvidenceReceipt showEvidenceReceipt
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
@@ -52,6 +63,7 @@ describe("PresentationFooter", () => {
}} }}
evidence={[]} evidence={[]}
demoRail={{ kind: "hidden" }} demoRail={{ kind: "hidden" }}
syncController={standaloneSyncController()}
retryHealth={vi.fn()} retryHealth={vi.fn()}
showEvidenceReceipt={false} showEvidenceReceipt={false}
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
@@ -80,6 +92,7 @@ describe("PresentationFooter", () => {
canRun: true, canRun: true,
canRetry: true, canRetry: true,
}} }}
syncController={standaloneSyncController()}
retryHealth={vi.fn()} retryHealth={vi.fn()}
showEvidenceReceipt={false} showEvidenceReceipt={false}
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
@@ -89,4 +102,38 @@ describe("PresentationFooter", () => {
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1); expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
expect(screen.getByText("Live target ready")).toBeInTheDocument(); expect(screen.getByText("Live target ready")).toBeInTheDocument();
}); });
it("keeps audience pairing in the non-demo utility area beside one demo rail", () => {
render(
<PresentationFooter
location={{ kind: "main", sceneId: "agent-handoff", beatId: "request", focusPath: [] }}
evidence={[]}
demoRail={{
kind: "action",
mode: "live",
label: "Run prepared workflow",
status: {
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
},
canRun: true,
canRetry: true,
}}
syncController={standaloneSyncController()}
retryHealth={vi.fn()}
showEvidenceReceipt={false}
inspectEvidence={vi.fn()}
/>,
);
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
const utility = footer.querySelector<HTMLElement>(".presentation-footer__utility");
expect(utility).not.toBeNull();
if (utility === null) throw new Error("presentation utility area is missing");
expect(within(utility).getByRole("complementary", { name: "Presentation pairing" }))
.toHaveAttribute("data-role", "audience");
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
});
}); });
@@ -5,6 +5,8 @@ import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
import type { DemoChromePresentation } from "./presentation-demo-chrome.js"; import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
import { PresentationDemoRail } from "./PresentationDemoRail.js"; import { PresentationDemoRail } from "./PresentationDemoRail.js";
import type { MainLocation } from "./storyboard.js"; import type { MainLocation } from "./storyboard.js";
import { PresentationPairingPanel } from "./sync/PresentationPairingPanel.js";
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
type PresentationFooterProps = { type PresentationFooterProps = {
readonly location: MainLocation; readonly location: MainLocation;
@@ -14,6 +16,7 @@ type PresentationFooterProps = {
readonly retryHealth: () => void; readonly retryHealth: () => void;
readonly showEvidenceReceipt: boolean; readonly showEvidenceReceipt: boolean;
readonly inspectEvidence: () => void; readonly inspectEvidence: () => void;
readonly syncController: PresentationSyncController;
}; };
export const PresentationFooter = ({ export const PresentationFooter = ({
@@ -24,6 +27,7 @@ export const PresentationFooter = ({
retryHealth, retryHealth,
showEvidenceReceipt, showEvidenceReceipt,
inspectEvidence, inspectEvidence,
syncController,
}: PresentationFooterProps) => ( }: PresentationFooterProps) => (
<footer className="presentation-footer" aria-label="presentation footer"> <footer className="presentation-footer" aria-label="presentation footer">
<SceneProgress location={location} /> <SceneProgress location={location} />
@@ -32,10 +36,13 @@ export const PresentationFooter = ({
runPreparedWorkflow={runPreparedWorkflow} runPreparedWorkflow={runPreparedWorkflow}
retryHealth={retryHealth} retryHealth={retryHealth}
/> />
<div className="presentation-footer__utility">
<PresentationPairingPanel role="audience" controller={syncController} />
<EvidenceReceipt <EvidenceReceipt
records={evidence} records={evidence}
visible={showEvidenceReceipt} visible={showEvidenceReceipt}
onInspect={inspectEvidence} onInspect={inspectEvidence}
/> />
</div>
</footer> </footer>
); );
@@ -32,6 +32,104 @@ const healthyResponse = {
durationMs: 2, durationMs: 2,
}; };
const syncGrant = {
sessionId: "session-route",
code: "ABC123",
connectionToken: "token-route",
websocketPath: "/api/presentation-sync/ws" as const,
snapshot: { hash: "#scene/thesis/title", revision: 0 },
};
class SyncTestSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
readonly sent: string[] = [];
readonly url: string;
readyState = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(url: string, private readonly sockets: SyncTestSocket[]) {
this.url = url;
sockets.push(this);
}
send(message: string): void {
this.sent.push(message);
}
open(): void {
this.readyState = SyncTestSocket.OPEN;
this.onopen?.();
}
serverMessage(message: unknown): void {
this.onmessage?.({ data: JSON.stringify(message) });
}
close(code = 1000, reason = "closed"): void {
this.readyState = SyncTestSocket.CLOSED;
this.onclose?.({ code, reason });
}
}
const installSyncTransport = (serverAvailable = true) => {
const sockets: SyncTestSocket[] = [];
const fetch = vi.fn<typeof globalThis.fetch>();
if (serverAvailable) {
fetch.mockResolvedValue(new Response(JSON.stringify(syncGrant), { status: 201 }));
} else {
fetch.mockRejectedValue(new Error("The pairing server is unavailable."));
}
vi.stubGlobal("fetch", fetch);
vi.stubGlobal(
"WebSocket",
class extends SyncTestSocket {
constructor(url: string) {
super(url, sockets);
}
},
);
return { fetch, sockets };
};
const locationSnapshot = (hash: string, revision: number) => ({
type: "location.snapshot",
snapshot: { hash, revision },
originatingMessageId: null,
});
const presenceSnapshot = {
type: "presence.snapshot",
presence: { presenters: 1, audience: 1 },
};
const publishedMessages = (socket: SyncTestSocket | undefined) =>
socket?.sent
.map((message) => JSON.parse(message) as { readonly type?: string; readonly hash?: string })
.filter((message) => message.type === "location.publish") ?? [];
const connectAudience = async (
user: ReturnType<typeof userEvent.setup>,
sockets: SyncTestSocket[],
initialHash = window.location.hash,
): Promise<SyncTestSocket> => {
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
await user.click(screen.getByRole("button", { name: "Start session" }));
await waitFor(() => expect(sockets).toHaveLength(1));
const socket = sockets[0];
if (socket === undefined) throw new Error("sync test socket was not created");
await act(async () => {
socket.open();
socket.serverMessage(locationSnapshot(initialHash, 0));
socket.serverMessage(presenceSnapshot);
});
await waitFor(() => expect(screen.getByRole("status", { name: "Connected" })).toBeInTheDocument());
return socket;
};
beforeAll(() => { beforeAll(() => {
mockedCallOperation.mockResolvedValue(healthyResponse); mockedCallOperation.mockResolvedValue(healthyResponse);
}); });
@@ -66,6 +164,7 @@ const graphNodeByLabel = (label: RegExp): HTMLElement => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
mockedCallOperation.mockReset(); mockedCallOperation.mockReset();
mockedCallOperation.mockResolvedValue(healthyResponse); mockedCallOperation.mockResolvedValue(healthyResponse);
window.sessionStorage.clear(); window.sessionStorage.clear();
@@ -73,6 +172,97 @@ afterEach(() => {
}); });
describe("PresentationRoute", () => { describe("PresentationRoute", () => {
it("renders the uniform audience pairing panel on /present", { timeout: 15000 }, async () => {
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
const panel = screen.getByRole("complementary", { name: "Presentation pairing" });
expect(panel).toHaveAttribute("data-role", "audience");
expect(within(panel).getByRole("button", { name: "Pair presentation" })).toBeInTheDocument();
});
it("publishes one complete hash for local ArrowRight navigation", async () => {
const user = userEvent.setup();
const { sockets } = installSyncTransport();
window.location.hash = "#scene/thesis/title";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
const socket = await connectAudience(user, sockets);
fireEvent.keyDown(document.body, { key: "ArrowRight" });
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
expect(publishedMessages(socket)[0]).toMatchObject({
type: "location.publish",
hash: "#scene/thesis/substrate",
});
});
it("publishes direct hashes and figure focus changes with their complete focus paths", async () => {
const user = userEvent.setup();
const { sockets } = installSyncTransport();
window.location.hash = "#scene/architecture/overview";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
const socket = await connectAudience(user, sockets);
window.location.hash = "#scene/architecture/runtime/focus/runtime-providers/configured-providers";
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
expect(publishedMessages(socket)[0]?.hash)
.toBe("#scene/architecture/runtime/focus/runtime-providers/configured-providers");
window.location.hash = "#scene/architecture/overview";
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(2));
await waitFor(() => expect(
Array.from(document.querySelectorAll<HTMLButtonElement>(".figure-node"))
.some((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? "")),
).toBe(true));
const capabilityInventoryNode = Array.from(
document.querySelectorAll<HTMLButtonElement>(".figure-node"),
).find((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? ""));
if (capabilityInventoryNode === undefined) throw new Error("capability inventory figure node is missing");
fireEvent.click(capabilityInventoryNode);
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(3));
expect(publishedMessages(socket)[2]?.hash)
.toBe("#scene/architecture/overview/focus/runtime-providers");
});
it("applies a remote discussion hash through the existing discussion panel without echoing it", async () => {
const user = userEvent.setup();
const { sockets } = installSyncTransport();
window.location.hash = "#scene/thesis/title";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
const socket = await connectAudience(user, sockets);
await act(async () => {
socket.serverMessage(locationSnapshot("#discuss/hosted-automation", 1));
});
expect(await screen.findByRole("button", { name: /return to positioning/i })).toBeInTheDocument();
await waitFor(() => expect(window.location.hash).toBe("#discuss/hosted-automation"));
expect(publishedMessages(socket)).toHaveLength(0);
});
it("keeps local Space and ArrowRight navigation working when pairing fails", async () => {
const user = userEvent.setup();
const { fetch } = installSyncTransport(false);
window.location.hash = "#scene/thesis/title";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
await user.click(screen.getByRole("button", { name: "Start session" }));
expect(await screen.findByRole("alert")).toHaveTextContent("The pairing server is unavailable.");
expect(fetch).toHaveBeenCalledOnce();
fireEvent.keyDown(document.body, { key: "ArrowRight" });
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
fireEvent.keyDown(document.body, { key: " " });
await waitFor(() => expect(window.location.hash).toBe("#scene/problem/direct-actions"));
});
const directHashRoutes = [ const directHashRoutes = [
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", hasDemoChrome: false }, { hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", hasDemoChrome: false },
{ hash: "#scene/architecture/overview", heading: "Architecture Zoom", hasDemoChrome: false }, { hash: "#scene/architecture/overview", heading: "Architecture Zoom", hasDemoChrome: false },
@@ -16,6 +16,7 @@ import {
import { hashForLocation, nextMainLocation } from "./storyboard-navigation.js"; import { hashForLocation, nextMainLocation } from "./storyboard-navigation.js";
import type { MainLocation } from "./storyboard.js"; import type { MainLocation } from "./storyboard.js";
import type { DemoApprovalActions, DemoApprovalUiState } from "./demo-approval-actions.js"; import type { DemoApprovalActions, DemoApprovalUiState } from "./demo-approval-actions.js";
import { usePresentationSync } from "./sync/usePresentationSync.js";
import "./presentation.css"; import "./presentation.css";
import "./styles/demo-workflow.css"; import "./styles/demo-workflow.css";
@@ -43,6 +44,12 @@ export const PresentationRoute = () => {
{ type: "jump_hash", hash: initialHash }, { type: "jump_hash", hash: initialHash },
), ),
); );
const canonicalHash = hashForLocation(state.location);
const presentationSync = usePresentationSync({
role: "audience",
currentHash: canonicalHash,
applyRemoteHash: (hash) => dispatch({ type: "jump_hash", hash }),
});
const [presentationReady, setPresentationReady] = useState(false); const [presentationReady, setPresentationReady] = useState(false);
useEffect(() => { useEffect(() => {
@@ -306,6 +313,7 @@ export const PresentationRoute = () => {
closeOverlay={() => dispatch({ type: "close_overlay" })} closeOverlay={() => dispatch({ type: "close_overlay" })}
openDiscussion={handleOpenDiscussion} openDiscussion={handleOpenDiscussion}
closeDiscussion={handleCloseDiscussion} closeDiscussion={handleCloseDiscussion}
syncController={presentationSync}
/> />
</PresentationCanvas> </PresentationCanvas>
</main> </main>
@@ -11,6 +11,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
import type { PresentationState } from "./presentation-state.js"; import type { PresentationState } from "./presentation-state.js";
import { compositionForState } from "./presentation-state.js"; import { compositionForState } from "./presentation-state.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js"; import type { PresentationTargetHealth } from "./presentation-target-status.js";
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
import { demoChromeFor } from "./presentation-demo-chrome.js"; import { demoChromeFor } from "./presentation-demo-chrome.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js"; import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { findScene, type MainLocation } from "./storyboard.js"; import { findScene, type MainLocation } from "./storyboard.js";
@@ -34,6 +35,7 @@ type PresentationStageProps = {
readonly closeOverlay: () => void; readonly closeOverlay: () => void;
readonly openDiscussion: (branchId: string) => void; readonly openDiscussion: (branchId: string) => void;
readonly closeDiscussion: () => void; readonly closeDiscussion: () => void;
readonly syncController: PresentationSyncController;
}; };
export const PresentationStage = ({ export const PresentationStage = ({
@@ -55,6 +57,7 @@ export const PresentationStage = ({
closeOverlay, closeOverlay,
openDiscussion, openDiscussion,
closeDiscussion, closeDiscussion,
syncController,
}: PresentationStageProps) => { }: PresentationStageProps) => {
const composition = compositionForState(state); const composition = compositionForState(state);
@@ -126,6 +129,7 @@ export const PresentationStage = ({
retryHealth={retryHealth} retryHealth={retryHealth}
showEvidenceReceipt={composition.evidencePresentation !== "hidden"} showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
inspectEvidence={openEvidence} inspectEvidence={openEvidence}
syncController={syncController}
/> />
)} )}
<EvidenceInspector <EvidenceInspector
@@ -1111,6 +1111,14 @@
padding: 0; padding: 0;
} }
.presentation-footer__utility {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.evidence-receipt { .evidence-receipt {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -1217,6 +1225,11 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.presentation-footer__utility {
flex: 0 1 auto;
max-width: 52%;
}
.presentation-demo-rail { .presentation-demo-rail {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;