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 { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||
import { PresentationStage } from "./PresentationStage.js";
|
||||
import { PresenterControls } from "./PresenterControls.js";
|
||||
import { ChatDock } from "./ChatDock.js";
|
||||
import {
|
||||
initialPresentationState,
|
||||
presentationReducer,
|
||||
@@ -84,6 +86,9 @@ export const PresentationRoute = () => {
|
||||
dispatch({ type: "previous" });
|
||||
} else if (event.key === "Escape") {
|
||||
dispatch({ type: "close_overlay" });
|
||||
} else if (event.key === "p" || event.key === "P") {
|
||||
if (!isBodyEvent) return;
|
||||
dispatch({ type: "toggle_controls" });
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<PresentationStage
|
||||
@@ -163,6 +180,21 @@ export const PresentationRoute = () => {
|
||||
openDiscussion={handleOpenDiscussion}
|
||||
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
|
||||
type="button"
|
||||
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