feat: synchronize presenter controls

This commit is contained in:
lda
2026-07-14 14:48:25 +07:00 Verified
parent ae0e1bee36
commit be3e494488
5 changed files with 203 additions and 6 deletions
@@ -1,11 +1,14 @@
import type { PresenterBeatNote } from "./presenter-notes.js";
import { presenterHashForNote } from "./presenter-navigation.js";
import { PresentationPairingPanel } from "../sync/PresentationPairingPanel.js";
import type { PresentationSyncController } from "../sync/presentation-sync-state.js";
type PresenterNavigationBarProps = {
readonly currentIndex: number;
readonly total: number;
readonly previous: PresenterBeatNote | null;
readonly next: PresenterBeatNote | null;
readonly syncController: PresentationSyncController;
};
const DirectionLink = ({ note, children }: { readonly note: PresenterBeatNote | null; readonly children: string }) =>
@@ -13,10 +16,11 @@ const DirectionLink = ({ note, children }: { readonly note: PresenterBeatNote |
? <a href={presenterHashForNote(note)}>{children}</a>
: <span aria-disabled="true">{children}</span>;
export const PresenterNavigationBar = ({ currentIndex, total, previous, next }: PresenterNavigationBarProps) => (
export const PresenterNavigationBar = ({ currentIndex, total, previous, next, syncController }: PresenterNavigationBarProps) => (
<nav className="presenter-navigation" aria-label="Presenter note navigation">
<DirectionLink note={previous}> Previous</DirectionLink>
<span>{currentIndex + 1} / {total}</span>
<DirectionLink note={next}>Next </DirectionLink>
<PresentationPairingPanel role="presenter" controller={syncController} />
</nav>
);
@@ -1,8 +1,28 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PresentationSyncController } from "../sync/presentation-sync-state.js";
import { usePresentationSync } from "../sync/usePresentationSync.js";
import { PresenterRoute } from "./PresenterRoute.js";
vi.mock("../sync/usePresentationSync.js", () => ({
usePresentationSync: vi.fn(),
}));
const mockedUsePresentationSync = vi.mocked(usePresentationSync);
const idleController = (): PresentationSyncController => ({
state: { kind: "standalone" },
startSession: vi.fn(async () => undefined),
joinSession: vi.fn(async () => undefined),
retry: vi.fn(),
leaveSession: vi.fn(),
endSession: vi.fn(),
});
beforeEach(() => {
mockedUsePresentationSync.mockReturnValue(idleController());
});
afterEach(() => {
cleanup();
window.location.hash = "";
@@ -44,6 +64,101 @@ describe("PresenterRoute", () => {
expect(window.location.hash).toBe("#scene/thesis/substrate");
});
it("mounts presenter synchronization beside stable navigation and publishes canonical hashes", async () => {
window.location.hash = "#scene/thesis/substrate";
render(<PresenterRoute />);
const navigation = screen.getByRole("navigation", { name: /presenter note navigation/i });
expect(navigation).toContainElement(screen.getByRole("button", { name: /pair presentation/i }));
expect(screen.getByRole("link", { name: /^Next →$/i })).toHaveAttribute(
"href",
"#scene/problem/direct-actions",
);
expect(screen.getAllByRole("link", { name: /Where is the AI agent/i })[0]).toHaveAttribute(
"href",
"#discuss/where-is-ai-agent",
);
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
role: "presenter",
currentHash: "#scene/thesis/substrate",
}));
await userEvent.click(screen.getByRole("link", { name: /previous/i }));
expect(window.location.hash).toBe("#scene/thesis/title");
fireEvent(window, new HashChangeEvent("hashchange"));
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
currentHash: "#scene/thesis/title",
}));
});
it("applies audience hashes to notes and Q&A without publishing the old hash", () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
const applyRemoteHash = mockedUsePresentationSync.mock.calls.at(-1)?.[0].applyRemoteHash;
expect(applyRemoteHash).toBeDefined();
act(() => {
applyRemoteHash?.("#scene/problem/direct-actions");
fireEvent(window, new HashChangeEvent("hashchange"));
});
expect(screen.getByRole("region", { name: "Beat goal" })).toHaveTextContent(
/Show why one successful chat is not yet automation/i,
);
expect(window.location.hash).toBe("#scene/problem/direct-actions");
act(() => {
applyRemoteHash?.("#discuss/where-is-ai-agent");
fireEvent(window, new HashChangeEvent("hashchange"));
});
expect(screen.getByRole("heading", { name: /Where is the AI agent/i })).toBeInTheDocument();
expect(mockedUsePresentationSync).toHaveBeenLastCalledWith(expect.objectContaining({
currentHash: "#discuss/where-is-ai-agent",
}));
});
it("ends presenter sessions after confirmation and displays ended state", async () => {
let controllerState: PresentationSyncController["state"] = {
kind: "connected",
grant: {
sessionId: "session-1",
code: "ABC123",
connectionToken: "token",
websocketPath: "/api/presentation-sync/ws",
snapshot: { hash: "#scene/thesis/title", revision: 1 },
},
snapshot: { hash: "#scene/thesis/title", revision: 1 },
presence: { presenters: 1, audience: 1 },
};
const controller = {
...idleController(),
get state() { return controllerState; },
};
mockedUsePresentationSync.mockReturnValue(controller);
const { rerender } = render(<PresenterRoute />);
await userEvent.click(screen.getByRole("button", { name: "End presentation" }));
await userEvent.click(screen.getByRole("button", { name: "End presentation now" }));
expect(controller.endSession).toHaveBeenCalledOnce();
controllerState = { kind: "ended", reason: "presenter_ended" };
rerender(<PresenterRoute />);
expect(screen.getByText("The presenter ended this session.")).toBeInTheDocument();
});
it("keeps local navigation available after synchronization fails", () => {
mockedUsePresentationSync.mockReturnValue({
...idleController(),
state: { kind: "failed", message: "Socket unavailable", retryable: true },
});
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(window.location.hash).toBe("#scene/thesis/substrate");
expect(screen.getByRole("alert")).toHaveTextContent("Socket unavailable");
});
it("advances from the latest hash during rapid consecutive navigation", () => {
window.location.hash = "#scene/thesis/title";
render(<PresenterRoute />);
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { findDiscussionBranch } from "../storyboard.js";
import { usePresentationSync } from "../sync/usePresentationSync.js";
import { PresenterNote } from "./PresenterNote.js";
import { PresenterNavigationBar } from "./PresenterNavigationBar.js";
import { PresenterShell } from "./PresenterShell.js";
@@ -19,6 +20,14 @@ const moveFromCurrentHash = (direction: "next" | "previous") => {
export const PresenterRoute = () => {
const [navigation, setNavigation] = useState(readHash);
const [covered, setCovered] = useState<ReadonlySet<string>>(() => new Set());
const presentationSync = usePresentationSync({
role: "presenter",
currentHash: window.location.hash || "#scene/thesis/title",
applyRemoteHash: (hash) => {
// Hash assignment keeps the existing parser as the single navigation path.
if (window.location.hash !== hash) window.location.hash = hash;
},
});
useEffect(() => {
const syncHash = () => setNavigation(readHash());
@@ -62,6 +71,7 @@ export const PresenterRoute = () => {
total={presenterNotes.length}
previous={navigation.previous}
next={navigation.next}
syncController={presentationSync}
/>
)}
{navigation.note && (
@@ -37,9 +37,9 @@
.presenter-sidebar__qna a[aria-current="page"] { color: #155b49; font-weight: 700; }
.presenter-route__reader { min-width: 0; padding: 1.25rem clamp(1.5rem, 5vw, 6rem) 5rem; touch-action: pan-y; }
.presenter-navigation { max-width: 72ch; min-height: 2.5rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; margin: 0 auto 2rem; border-bottom: 1px solid #d7d5d0; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
.presenter-navigation { max-width: 72ch; min-height: 2.5rem; display: grid; grid-template-columns: 1fr auto 1fr auto; align-items: center; gap: 1rem; margin: 0 auto 2rem; border-bottom: 1px solid #d7d5d0; color: #67655f; font: 0.78rem "IBM Plex Mono", monospace; }
.presenter-navigation a { color: #155b49; font-weight: 650; text-decoration: none; }
.presenter-navigation a:last-child, .presenter-navigation > span:last-child { text-align: right; }
.presenter-navigation > a:nth-child(3), .presenter-navigation > span:nth-child(3) { text-align: right; }
.presenter-navigation [aria-disabled="true"] { opacity: 0.35; }
.presenter-note, .presenter-qna { max-width: 72ch; margin: 0 auto; }
.presenter-note__header { display: flex; justify-content: space-between; gap: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #20201e; }
@@ -77,7 +77,26 @@
.presenter-route { grid-template-columns: 1fr; }
.presenter-sidebar { position: static; height: auto; max-height: 15rem; border-right: 0; border-bottom: 1px solid #d7d5d0; }
.presenter-sidebar ol { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.presenter-navigation { position: sticky; top: 0; z-index: 3; margin-inline: -0.5rem; padding-inline: 0.5rem; background: #f7f7f5; }
.presenter-navigation {
position: sticky;
top: 0;
z-index: 3;
isolation: isolate;
padding-inline: 0.5rem;
border-bottom: 0;
}
/* Extend the sticky surface without replacing the nav's centered auto margins. */
.presenter-navigation::before {
content: "";
position: absolute;
inset: 0 auto 0 50%;
z-index: -1;
width: 100vw;
transform: translateX(-50%);
border-bottom: 1px solid #d7d5d0;
background: #f7f7f5;
pointer-events: none;
}
.presenter-note__goal p { font-size: 1.42rem; }
}