feat: add presentation controls and chat dock
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
type ChatDockProps = {
|
||||||
|
readonly openChat: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatDock = ({ openChat }: ChatDockProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-dock"
|
||||||
|
aria-label="open agent chat"
|
||||||
|
onClick={openChat}
|
||||||
|
>
|
||||||
|
<span className="chat-dock__icon">💬</span>
|
||||||
|
<span className="chat-dock__label">Chat</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -5,6 +5,8 @@ import { useDemoAgent } from "../demo/agent/useDemoAgent.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 { PresentationStage } from "./PresentationStage.js";
|
import { PresentationStage } from "./PresentationStage.js";
|
||||||
|
import { PresenterControls } from "./PresenterControls.js";
|
||||||
|
import { ChatDock } from "./ChatDock.js";
|
||||||
import {
|
import {
|
||||||
initialPresentationState,
|
initialPresentationState,
|
||||||
presentationReducer,
|
presentationReducer,
|
||||||
@@ -84,6 +86,9 @@ export const PresentationRoute = () => {
|
|||||||
dispatch({ type: "previous" });
|
dispatch({ type: "previous" });
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
dispatch({ type: "close_overlay" });
|
dispatch({ type: "close_overlay" });
|
||||||
|
} else if (event.key === "p" || event.key === "P") {
|
||||||
|
if (!isBodyEvent) return;
|
||||||
|
dispatch({ type: "toggle_controls" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
@@ -147,6 +152,18 @@ export const PresentationRoute = () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleForceReplay = useCallback(() => {
|
||||||
|
demo.setMode("replay");
|
||||||
|
}, [demo]);
|
||||||
|
|
||||||
|
const handleResetOverrides = useCallback(() => {
|
||||||
|
dispatch({ type: "set_stage_theme", theme: null });
|
||||||
|
dispatch({ type: "set_chat_theme", theme: null });
|
||||||
|
dispatch({ type: "set_chat_mode", mode: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showChatDock = state.chatModeOverride === "dock" && state.location.kind !== "discussion";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
<PresentationStage
|
<PresentationStage
|
||||||
@@ -163,6 +180,21 @@ export const PresentationRoute = () => {
|
|||||||
openDiscussion={handleOpenDiscussion}
|
openDiscussion={handleOpenDiscussion}
|
||||||
closeDiscussion={handleCloseDiscussion}
|
closeDiscussion={handleCloseDiscussion}
|
||||||
/>
|
/>
|
||||||
|
{showChatDock && <ChatDock openChat={() => dispatch({ type: "set_chat_mode", mode: "rail" })} />}
|
||||||
|
{state.controlsOpen && (
|
||||||
|
<PresenterControls
|
||||||
|
state={state}
|
||||||
|
next={() => dispatch({ type: "next" })}
|
||||||
|
previous={() => dispatch({ type: "previous" })}
|
||||||
|
jump={handleJump}
|
||||||
|
setStageTheme={(theme) => dispatch({ type: "set_stage_theme", theme })}
|
||||||
|
setChatTheme={(theme) => dispatch({ type: "set_chat_theme", theme })}
|
||||||
|
setChatMode={(mode) => dispatch({ type: "set_chat_mode", mode })}
|
||||||
|
forceReplay={handleForceReplay}
|
||||||
|
openDiscussionIndex={() => dispatch({ type: "toggle_controls" })}
|
||||||
|
resetOverrides={handleResetOverrides}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => agent.startPreparedReplay()}
|
onClick={() => agent.startPreparedReplay()}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PresenterControls } from "./PresenterControls.js";
|
||||||
|
import { ChatDock } from "./ChatDock.js";
|
||||||
|
import { initialPresentationState } from "./presentation-state.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("PresenterControls", () => {
|
||||||
|
const props = {
|
||||||
|
state: initialPresentationState,
|
||||||
|
next: vi.fn(),
|
||||||
|
previous: vi.fn(),
|
||||||
|
jump: vi.fn(),
|
||||||
|
setStageTheme: vi.fn(),
|
||||||
|
setChatTheme: vi.fn(),
|
||||||
|
setChatMode: vi.fn(),
|
||||||
|
forceReplay: vi.fn(),
|
||||||
|
openDiscussionIndex: vi.fn(),
|
||||||
|
resetOverrides: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("changes stage and chat themes independently", async () => {
|
||||||
|
render(<PresenterControls {...props} />);
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/stage theme/i), "night");
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/chat theme/i), "light");
|
||||||
|
expect(props.setStageTheme).toHaveBeenCalledWith("night");
|
||||||
|
expect(props.setChatTheme).toHaveBeenCalledWith("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces replay without changing the current presentation location", async () => {
|
||||||
|
render(<PresenterControls {...props} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /force replay fallback/i }));
|
||||||
|
expect(props.forceReplay).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.jump).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatDock", () => {
|
||||||
|
it("opens docked chat by click and keyboard", async () => {
|
||||||
|
const openChat = vi.fn();
|
||||||
|
render(<ChatDock openChat={openChat} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /open agent chat/i }));
|
||||||
|
expect(openChat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
|
import { compositionForState } from "./presentation-state.js";
|
||||||
|
import type { ChatMode, ChatTheme, MainLocation, PresentationLocation, StageTheme } from "./storyboard.js";
|
||||||
|
import { findScene, mainScenes } from "./storyboard.js";
|
||||||
|
|
||||||
|
type PresenterControlsProps = {
|
||||||
|
readonly state: PresentationState;
|
||||||
|
readonly next: () => void;
|
||||||
|
readonly previous: () => void;
|
||||||
|
readonly jump: (location: PresentationLocation) => void;
|
||||||
|
readonly setStageTheme: (theme: StageTheme | null) => void;
|
||||||
|
readonly setChatTheme: (theme: ChatTheme | null) => void;
|
||||||
|
readonly setChatMode: (mode: ChatMode | null) => void;
|
||||||
|
readonly forceReplay: () => void;
|
||||||
|
readonly openDiscussionIndex: () => void;
|
||||||
|
readonly resetOverrides: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PresenterControls = ({
|
||||||
|
state,
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
jump,
|
||||||
|
setStageTheme,
|
||||||
|
setChatTheme,
|
||||||
|
setChatMode,
|
||||||
|
forceReplay,
|
||||||
|
openDiscussionIndex,
|
||||||
|
resetOverrides,
|
||||||
|
}: PresenterControlsProps) => {
|
||||||
|
const composition = compositionForState(state);
|
||||||
|
const isMain = state.location.kind === "main";
|
||||||
|
const currentScene = isMain ? findScene(state.location.sceneId) : null;
|
||||||
|
const currentBeat = currentScene?.beats.find((b) => b.id === (state.location as MainLocation).beatId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="presenter-controls" role="dialog" aria-label="presenter controls">
|
||||||
|
<div className="presenter-controls__nav">
|
||||||
|
<button type="button" onClick={previous}>Previous</button>
|
||||||
|
<button type="button" onClick={next}>Next</button>
|
||||||
|
</div>
|
||||||
|
<div className="presenter-controls__info">
|
||||||
|
<span>{currentScene?.title ?? "Discussion"}</span>
|
||||||
|
{currentBeat && <span> · {currentBeat.title}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="presenter-controls__over">
|
||||||
|
<label>
|
||||||
|
Stage theme
|
||||||
|
<select
|
||||||
|
value={state.stageThemeOverride ?? "scene default"}
|
||||||
|
onChange={(e) => setStageTheme(e.target.value === "scene default" ? null : e.target.value as StageTheme)}
|
||||||
|
>
|
||||||
|
<option value="scene default">scene default</option>
|
||||||
|
<option value="paper">paper</option>
|
||||||
|
<option value="night">night</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Chat theme
|
||||||
|
<select
|
||||||
|
value={state.chatThemeOverride ?? "scene default"}
|
||||||
|
onChange={(e) => setChatTheme(e.target.value === "scene default" ? null : e.target.value as ChatTheme)}
|
||||||
|
>
|
||||||
|
<option value="scene default">scene default</option>
|
||||||
|
<option value="light">light</option>
|
||||||
|
<option value="dark">dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Chat mode
|
||||||
|
<select
|
||||||
|
value={state.chatModeOverride ?? "scene default"}
|
||||||
|
onChange={(e) => setChatMode(e.target.value === "scene default" ? null : e.target.value as ChatMode)}
|
||||||
|
>
|
||||||
|
<option value="scene default">scene default</option>
|
||||||
|
<option value="hidden">hidden</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
<option value="rail">rail</option>
|
||||||
|
<option value="dock">dock</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="presenter-controls__actions">
|
||||||
|
<span>{state.playbackMode === "replay" ? "Replay" : "Live"}</span>
|
||||||
|
<button type="button" onClick={forceReplay}>Force replay fallback</button>
|
||||||
|
<button type="button" onClick={openDiscussionIndex}>Open discussion index</button>
|
||||||
|
<button type="button" onClick={resetOverrides}>Reset overrides</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user