feat: add presentation discussion branches
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { discussionBranches, findScene, type MainSceneId } from "./storyboard.js";
|
||||
|
||||
type DiscussionIndexProps = {
|
||||
readonly onSelect: (branchId: string) => void;
|
||||
};
|
||||
|
||||
type BranchGroup = {
|
||||
readonly parentSceneId: MainSceneId;
|
||||
readonly sceneTitle: string;
|
||||
readonly branches: typeof discussionBranches[number][];
|
||||
};
|
||||
|
||||
const groupByParentScene = (): readonly BranchGroup[] => {
|
||||
const groups = new Map<string, BranchGroup>();
|
||||
for (const branch of discussionBranches) {
|
||||
let group = groups.get(branch.parentSceneId);
|
||||
if (!group) {
|
||||
const scene = findScene(branch.parentSceneId);
|
||||
group = {
|
||||
parentSceneId: branch.parentSceneId,
|
||||
sceneTitle: scene?.title ?? branch.parentSceneId,
|
||||
branches: [],
|
||||
};
|
||||
groups.set(branch.parentSceneId, group);
|
||||
}
|
||||
group.branches.push(branch);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
export const DiscussionIndex = ({ onSelect }: DiscussionIndexProps) => {
|
||||
const groups = groupByParentScene();
|
||||
return (
|
||||
<div className="discussion-index" role="dialog" aria-label="discussion topics">
|
||||
<h2>Discussion Topics</h2>
|
||||
{groups.map((group) => (
|
||||
<section key={group.parentSceneId}>
|
||||
<h3>{group.sceneTitle}</h3>
|
||||
<ul>
|
||||
{group.branches.map((branch) => (
|
||||
<li key={branch.id}>
|
||||
<button type="button" onClick={() => onSelect(branch.id)}>
|
||||
{branch.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { findDiscussionBranch, findScene } from "./storyboard.js";
|
||||
|
||||
type DiscussionPanelProps = {
|
||||
readonly branchId: string;
|
||||
readonly onClose: () => void;
|
||||
};
|
||||
|
||||
export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) => {
|
||||
const branch = findDiscussionBranch(branchId);
|
||||
if (!branch) return null;
|
||||
|
||||
const parentScene = findScene(branch.parentSceneId);
|
||||
|
||||
return (
|
||||
<div className="discussion-panel" role="dialog" aria-label={branch.title}>
|
||||
<header>
|
||||
<h2>{branch.title}</h2>
|
||||
<span className="discussion-panel__badge">{branch.claimClass}</span>
|
||||
</header>
|
||||
<p className="discussion-panel__evidence">{branch.evidencePointer}</p>
|
||||
<p className="discussion-panel__summary">{branch.summary}</p>
|
||||
{branchId === "hosted-automation" && (
|
||||
<p className="discussion-panel__detail">
|
||||
A future scheduler could trigger a workflow that launches a verified headless
|
||||
coding-agent command with a stored prompt. lda.chat does not implement that
|
||||
trigger or scheduler in the submitted scope.
|
||||
</p>
|
||||
)}
|
||||
{branchId === "mcp-agent-scale" && (
|
||||
<p className="discussion-panel__detail">
|
||||
<a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer">Model Context Protocol</a> ·{" "}
|
||||
<a href="https://developers.cloudflare.com/workers-ai/configuration/code-mode/" target="_blank" rel="noopener noreferrer">Cloudflare Code Mode</a>
|
||||
{" "}— both are external context.
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="discussion-panel__return">
|
||||
Return to {parentScene?.title ?? "scene"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -62,4 +62,23 @@ describe("PresentationRoute", () => {
|
||||
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBeGreaterThanOrEqual(2);
|
||||
expect(await screen.findByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a positioning branch and returns to the exact originating beat", async () => {
|
||||
window.location.hash = "#scene/positioning/lda-position";
|
||||
render(<PresentationRoute />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /open discussion topics/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /hosted automation/i }));
|
||||
expect(window.location.hash).toBe("#discuss/hosted-automation");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /return to positioning/i }));
|
||||
expect(window.location.hash).toBe("#scene/positioning/lda-position");
|
||||
});
|
||||
|
||||
it("uses the parent scene as return location for a directly linked branch", async () => {
|
||||
window.location.hash = "#discuss/mcp-agent-scale";
|
||||
render(<PresentationRoute />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /return to positioning/i }));
|
||||
expect(window.location.hash).toBe("#scene/positioning/landscape");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,16 @@ export const PresentationRoute = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenDiscussion = useCallback(
|
||||
(branchId: string) => dispatch({ type: "open_discussion", branchId }),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCloseDiscussion = useCallback(
|
||||
() => dispatch({ type: "close_discussion" }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<PresentationStage
|
||||
@@ -150,6 +160,8 @@ export const PresentationRoute = () => {
|
||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||
openDiscussion={handleOpenDiscussion}
|
||||
closeDiscussion={handleCloseDiscussion}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import type { AgentMessage } from "../demo/agent/events.js";
|
||||
import { SceneBody } from "./SceneBody.js";
|
||||
import { SceneRail } from "./SceneRail.js";
|
||||
import { DiscussionIndex } from "./DiscussionIndex.js";
|
||||
import { DiscussionPanel } from "./DiscussionPanel.js";
|
||||
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||
import { OperatorChat } from "./OperatorChat.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import type { MainLocation, PresentationLocation } from "./storyboard.js";
|
||||
import { discussionBranches, findScene, type PresentationLocation } from "./storyboard.js";
|
||||
|
||||
type PresentationStageProps = {
|
||||
readonly state: PresentationState;
|
||||
@@ -20,6 +23,8 @@ type PresentationStageProps = {
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly openEvidence: () => void;
|
||||
readonly closeOverlay: () => void;
|
||||
readonly openDiscussion: (branchId: string) => void;
|
||||
readonly closeDiscussion: () => void;
|
||||
};
|
||||
|
||||
export const PresentationStage = ({
|
||||
@@ -33,8 +38,17 @@ export const PresentationStage = ({
|
||||
selectNode,
|
||||
openEvidence,
|
||||
closeOverlay,
|
||||
openDiscussion,
|
||||
closeDiscussion,
|
||||
}: PresentationStageProps) => {
|
||||
const composition = compositionForState(state);
|
||||
const [indexOpen, setIndexOpen] = useState(false);
|
||||
|
||||
const isMainScene = state.location.kind === "main";
|
||||
const currentScene = isMainScene ? findScene(state.location.sceneId) : null;
|
||||
const hasBranches = currentScene
|
||||
? discussionBranches.some((b) => b.parentSceneId === currentScene.id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -47,12 +61,35 @@ export const PresentationStage = ({
|
||||
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
|
||||
</aside>
|
||||
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
||||
<SceneBody
|
||||
location={state.location}
|
||||
demo={demo}
|
||||
selectedNodeId={state.selectedNodeId}
|
||||
selectNode={selectNode}
|
||||
/>
|
||||
{state.location.kind === "discussion" ? (
|
||||
<DiscussionPanel branchId={state.location.branchId} onClose={closeDiscussion} />
|
||||
) : (
|
||||
<>
|
||||
<SceneBody
|
||||
location={state.location}
|
||||
demo={demo}
|
||||
selectedNodeId={state.selectedNodeId}
|
||||
selectNode={selectNode}
|
||||
/>
|
||||
{hasBranches && (
|
||||
<button
|
||||
type="button"
|
||||
className="presentation-stage__discussion-toggle"
|
||||
onClick={() => setIndexOpen(true)}
|
||||
>
|
||||
Open discussion topics
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{indexOpen && (
|
||||
<DiscussionIndex
|
||||
onSelect={(branchId) => {
|
||||
setIndexOpen(false);
|
||||
openDiscussion(branchId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="presentation-stage__mode">
|
||||
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
||||
</p>
|
||||
|
||||
@@ -136,9 +136,13 @@ export const presentationReducer = (
|
||||
case "jump_hash": {
|
||||
const parsed = locationFromHash(action.hash);
|
||||
if (parsed.kind === "main") {
|
||||
return { ...state, location: clampMainLocation(parsed) };
|
||||
return { ...state, location: clampMainLocation(parsed), discussionReturn: null };
|
||||
}
|
||||
return { ...state, location: parsed };
|
||||
const branch = findDiscussionBranch(parsed.branchId);
|
||||
const returnLoc = branch
|
||||
? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation
|
||||
: defaultMainLocation;
|
||||
return { ...state, location: parsed, discussionReturn: returnLoc };
|
||||
}
|
||||
case "open_discussion": {
|
||||
const branch = findDiscussionBranch(action.branchId);
|
||||
|
||||
Reference in New Issue
Block a user