feat: add presentation controls and chat dock

This commit is contained in:
lda
2026-07-04 16:53:00 +07:00 Verified
parent 90b5074a59
commit ea23f7763a
4 changed files with 186 additions and 0 deletions
@@ -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>
);
};