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(screen.getAllByText(/selectWorkflowNode/i).length).toBeGreaterThanOrEqual(2);
|
||||||
expect(await screen.findByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
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 (
|
return (
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
<PresentationStage
|
<PresentationStage
|
||||||
@@ -150,6 +160,8 @@ export const PresentationRoute = () => {
|
|||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||||
|
openDiscussion={handleOpenDiscussion}
|
||||||
|
closeDiscussion={handleCloseDiscussion}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import type { AgentMessage } from "../demo/agent/events.js";
|
import type { AgentMessage } from "../demo/agent/events.js";
|
||||||
import { SceneBody } from "./SceneBody.js";
|
import { SceneBody } from "./SceneBody.js";
|
||||||
import { SceneRail } from "./SceneRail.js";
|
import { SceneRail } from "./SceneRail.js";
|
||||||
|
import { DiscussionIndex } from "./DiscussionIndex.js";
|
||||||
|
import { DiscussionPanel } from "./DiscussionPanel.js";
|
||||||
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||||
import { OperatorChat } from "./OperatorChat.js";
|
import { OperatorChat } from "./OperatorChat.js";
|
||||||
import type { PresentationState } from "./presentation-state.js";
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
import { compositionForState } from "./presentation-state.js";
|
import { compositionForState } from "./presentation-state.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.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 = {
|
type PresentationStageProps = {
|
||||||
readonly state: PresentationState;
|
readonly state: PresentationState;
|
||||||
@@ -20,6 +23,8 @@ type PresentationStageProps = {
|
|||||||
readonly selectNode: (nodeId: string) => void;
|
readonly selectNode: (nodeId: string) => void;
|
||||||
readonly openEvidence: () => void;
|
readonly openEvidence: () => void;
|
||||||
readonly closeOverlay: () => void;
|
readonly closeOverlay: () => void;
|
||||||
|
readonly openDiscussion: (branchId: string) => void;
|
||||||
|
readonly closeDiscussion: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PresentationStage = ({
|
export const PresentationStage = ({
|
||||||
@@ -33,8 +38,17 @@ export const PresentationStage = ({
|
|||||||
selectNode,
|
selectNode,
|
||||||
openEvidence,
|
openEvidence,
|
||||||
closeOverlay,
|
closeOverlay,
|
||||||
|
openDiscussion,
|
||||||
|
closeDiscussion,
|
||||||
}: PresentationStageProps) => {
|
}: PresentationStageProps) => {
|
||||||
const composition = compositionForState(state);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -47,12 +61,35 @@ export const PresentationStage = ({
|
|||||||
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
|
<OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
<section className="presentation-stage__primary" aria-label="primary presentation region">
|
||||||
<SceneBody
|
{state.location.kind === "discussion" ? (
|
||||||
location={state.location}
|
<DiscussionPanel branchId={state.location.branchId} onClose={closeDiscussion} />
|
||||||
demo={demo}
|
) : (
|
||||||
selectedNodeId={state.selectedNodeId}
|
<>
|
||||||
selectNode={selectNode}
|
<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">
|
<p className="presentation-stage__mode">
|
||||||
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -136,9 +136,13 @@ export const presentationReducer = (
|
|||||||
case "jump_hash": {
|
case "jump_hash": {
|
||||||
const parsed = locationFromHash(action.hash);
|
const parsed = locationFromHash(action.hash);
|
||||||
if (parsed.kind === "main") {
|
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": {
|
case "open_discussion": {
|
||||||
const branch = findDiscussionBranch(action.branchId);
|
const branch = findDiscussionBranch(action.branchId);
|
||||||
|
|||||||
Reference in New Issue
Block a user