fix: address presentation review findings
This commit is contained in:
@@ -16,7 +16,7 @@ type DemoWorkflowSceneProps = {
|
||||
readonly beat: SceneBeatDefinition;
|
||||
readonly demo: DemoTimelineController;
|
||||
readonly selectedNodeId: string | null;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly selectNode: (nodeId: string | null) => void;
|
||||
readonly openEvidence: () => void;
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ export const DemoWorkflowScene = ({
|
||||
</div>
|
||||
|
||||
{selectedNodeId && (
|
||||
<NodeSpotlight nodeId={selectedNodeId} close={() => selectNode("")} />
|
||||
<NodeSpotlight nodeId={selectedNodeId} close={() => selectNode(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiscussionPanel } from "./DiscussionPanel.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -8,9 +8,14 @@ afterEach(() => cleanup());
|
||||
describe("DiscussionPanel", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onClose.mockClear();
|
||||
});
|
||||
|
||||
it("renders the branch title and claim class", () => {
|
||||
render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />);
|
||||
expect(screen.getByRole("dialog")).toHaveAttribute("aria-label", "Hosted automation");
|
||||
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByText("Hosted automation")).toBeDefined();
|
||||
expect(screen.getByText("future-work")).toBeDefined();
|
||||
});
|
||||
@@ -28,6 +33,30 @@ describe("DiscussionPanel", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("focuses the return button and closes on Escape", async () => {
|
||||
render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />);
|
||||
|
||||
const returnButton = screen.getByRole("button", { name: /return/i });
|
||||
expect(document.activeElement).toBe(returnButton);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("traps tab focus inside the dialog", async () => {
|
||||
render(<DiscussionPanel branchId="mcp-agent-scale" onClose={onClose} />);
|
||||
|
||||
const firstLink = screen.getByRole("link", { name: "Anthropic MCP" });
|
||||
const returnButton = screen.getByRole("button", { name: /return/i });
|
||||
returnButton.focus();
|
||||
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(firstLink);
|
||||
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(returnButton);
|
||||
});
|
||||
|
||||
it("shows hosted-automation detail paragraph", () => {
|
||||
render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />);
|
||||
expect(screen.getByText(/future scheduler/)).toBeDefined();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { findDiscussionBranch, findScene } from "./storyboard.js";
|
||||
|
||||
type DiscussionPanelProps = {
|
||||
@@ -7,33 +8,72 @@ type DiscussionPanelProps = {
|
||||
|
||||
export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) => {
|
||||
const branch = findDiscussionBranch(branchId);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const returnButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!branch) return;
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
returnButtonRef.current?.focus();
|
||||
return () => previouslyFocused?.focus();
|
||||
}, [branch]);
|
||||
|
||||
if (!branch) return null;
|
||||
|
||||
const parentScene = findScene(branch.parentSceneId);
|
||||
|
||||
const trapKeyboardWithinDialog = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [])].filter((element) => !element.hasAttribute("disabled"));
|
||||
const first = focusable.at(0);
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discussion-panel" role="dialog" aria-label={branch.title}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="discussion-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={branch.title}
|
||||
onKeyDown={trapKeyboardWithinDialog}
|
||||
>
|
||||
<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" && (
|
||||
{branch.detail && (
|
||||
<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.
|
||||
{branch.detail.links?.map((link, index) => (
|
||||
<span key={link.href}>
|
||||
{index > 0 && " · "}
|
||||
<a href={link.href} target="_blank" rel="noopener noreferrer">{link.label}</a>
|
||||
</span>
|
||||
))}
|
||||
{branch.detail.links && branch.detail.links.length > 0 ? " — " : ""}
|
||||
{branch.detail.text}
|
||||
</p>
|
||||
)}
|
||||
{branchId === "mcp-agent-scale" && (
|
||||
<p className="discussion-panel__detail">
|
||||
<a href="https://www.anthropic.com/engineering/code-execution-with-mcp" target="_blank" rel="noopener noreferrer">Anthropic MCP</a> ·{" "}
|
||||
<a href="https://blog.cloudflare.com/code-mode-mcp/" target="_blank" rel="noopener noreferrer">Cloudflare Code Mode</a>
|
||||
{" "}— both are external context.
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="discussion-panel__return">
|
||||
<button ref={returnButtonRef} type="button" onClick={onClose} className="discussion-panel__return">
|
||||
Return to {parentScene?.title ?? "scene"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { OperatorChat } from "./OperatorChat.js";
|
||||
import { initialPresentationState } from "./presentation-state.js";
|
||||
import type { AgentMessage } from "../demo/agent/events.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("OperatorChat", () => {
|
||||
it("renders standard agent message parts", () => {
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
@@ -33,4 +36,77 @@ describe("OperatorChat", () => {
|
||||
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2);
|
||||
expect(screen.getByText(/tool result/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback messages when no agent messages are present", () => {
|
||||
render(<OperatorChat state={initialPresentationState} />);
|
||||
|
||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Found prepared workflow recipe/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders approval controls and wires decisions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onApprove = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
id: "approval",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "approval-request",
|
||||
callId: "call-1",
|
||||
name: "resumeIssueReview",
|
||||
prompt: "Approve resuming?",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Approve" }));
|
||||
await user.click(screen.getByRole("button", { name: "Deny" }));
|
||||
expect(onApprove).toHaveBeenCalledTimes(1);
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders error and presentation action parts", () => {
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
id: "mixed",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "presentation-action", action: { type: "selectWorkflowNode", nodeId: "review_issues" } },
|
||||
{ type: "error", message: "provider failed" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.getByText("Presentation action")).toBeInTheDocument();
|
||||
expect(screen.getByText("selectWorkflowNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("provider failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders prepared run tool calls as workflow handoffs", () => {
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
id: "start",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-call",
|
||||
call: { id: "call-1", name: "startPreparedReportRun", input: { deploymentId: "demo" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.getByText("Workflow operation")).toBeInTheDocument();
|
||||
expect(screen.getByText("startPreparedReportRun")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,11 @@ import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||
import { PresentationCanvas } from "./PresentationCanvas.js";
|
||||
import { PresentationStage } from "./PresentationStage.js";
|
||||
import {
|
||||
initialPresentationState,
|
||||
createInitialPresentationState,
|
||||
presentationReducer,
|
||||
} from "./presentation-state.js";
|
||||
import { hashForLocation } from "./storyboard-navigation.js";
|
||||
import type { PresentationLocation } from "./storyboard.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
import "./presentation.css";
|
||||
import "./styles/demo-workflow.css";
|
||||
|
||||
@@ -33,10 +33,10 @@ const projectRecordingToEvidence = (
|
||||
export const PresentationRoute = () => {
|
||||
const [state, dispatch] = useReducer(
|
||||
presentationReducer,
|
||||
initialPresentationState,
|
||||
(initial) => presentationReducer(
|
||||
{ ...initial, startedAt: Date.now() },
|
||||
{ type: "jump_hash", hash: window.location.hash },
|
||||
window.location.hash,
|
||||
(initialHash) => presentationReducer(
|
||||
createInitialPresentationState(),
|
||||
{ type: "jump_hash", hash: initialHash },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -127,10 +127,10 @@ export const PresentationRoute = () => {
|
||||
if (agent.pendingActions.length > 0) {
|
||||
agent.clearPendingActions();
|
||||
}
|
||||
}, [agent.pendingActions, agent.clearPendingActions, evidence.length, replayEvidence]);
|
||||
}, [agent.pendingActions, agent.clearPendingActions]);
|
||||
|
||||
const handleJump = useCallback(
|
||||
(location: PresentationLocation) => dispatch({ type: "jump", location }),
|
||||
(location: MainLocation) => dispatch({ type: "jump", location }),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import { findScene, type MainLocation, type PresentationLocation } from "./storyboard.js";
|
||||
import { findScene, type MainLocation } from "./storyboard.js";
|
||||
|
||||
type PresentationStageProps = {
|
||||
readonly state: PresentationState;
|
||||
@@ -18,8 +18,8 @@ type PresentationStageProps = {
|
||||
readonly messages?: ReadonlyArray<AgentMessage>;
|
||||
readonly onApprove?: (() => void) | undefined;
|
||||
readonly onDeny?: (() => void) | undefined;
|
||||
readonly jump: (location: PresentationLocation) => void;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly jump: (location: MainLocation) => void;
|
||||
readonly selectNode: (nodeId: string | null) => void;
|
||||
readonly openEvidence: () => void;
|
||||
readonly closeOverlay: () => void;
|
||||
readonly openDiscussion: (branchId: string) => void;
|
||||
@@ -69,6 +69,7 @@ export const PresentationStage = ({
|
||||
selectedNodeId={state.selectedNodeId}
|
||||
selectNode={selectNode}
|
||||
openEvidence={openEvidence}
|
||||
openDiscussion={openDiscussion}
|
||||
onFocusPathChange={(path) => {
|
||||
if (state.location.kind === "main") {
|
||||
jump({ ...state.location, focusPath: path });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import { SceneBody } from "./SceneBody.js";
|
||||
@@ -46,6 +47,7 @@ describe("SceneBody", () => {
|
||||
selectedNodeId={null}
|
||||
selectNode={noop}
|
||||
openEvidence={noop}
|
||||
openDiscussion={noop}
|
||||
onFocusPathChange={noop}
|
||||
motionDisabled={false}
|
||||
/>,
|
||||
@@ -63,10 +65,33 @@ describe("SceneBody", () => {
|
||||
selectedNodeId={null}
|
||||
selectNode={noop}
|
||||
openEvidence={noop}
|
||||
openDiscussion={noop}
|
||||
onFocusPathChange={noop}
|
||||
motionDisabled={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText(/workflow graph/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a scene discussion branch from the scene body", async () => {
|
||||
const user = userEvent.setup();
|
||||
const location: PresentationLocation = { kind: "main", sceneId: "positioning", beatId: "landscape", focusPath: [] };
|
||||
const openDiscussion = vi.fn();
|
||||
render(
|
||||
<SceneBody
|
||||
location={location}
|
||||
demo={demo}
|
||||
selectedNodeId={null}
|
||||
selectNode={noop}
|
||||
openEvidence={noop}
|
||||
openDiscussion={openDiscussion}
|
||||
onFocusPathChange={noop}
|
||||
motionDisabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /hosted automation/i }));
|
||||
|
||||
expect(openDiscussion).toHaveBeenCalledWith("hosted-automation");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import { findBeat, findScene, type PresentationLocation, type SceneDefinition, type SceneBeatDefinition } from "./storyboard.js";
|
||||
import {
|
||||
discussionBranches,
|
||||
findBeat,
|
||||
findScene,
|
||||
type PresentationLocation,
|
||||
type SceneDefinition,
|
||||
type SceneBeatDefinition,
|
||||
} from "./storyboard.js";
|
||||
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
|
||||
import { StageCaption } from "./StageCaption.js";
|
||||
import { ArchitectureScene } from "./scenes/ArchitectureScene.js";
|
||||
@@ -8,12 +15,37 @@ type SceneBodyProps = {
|
||||
readonly location: PresentationLocation;
|
||||
readonly demo: DemoTimelineController;
|
||||
readonly selectedNodeId: string | null;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly selectNode: (nodeId: string | null) => void;
|
||||
readonly openEvidence: () => void;
|
||||
readonly openDiscussion: (branchId: string) => void;
|
||||
readonly onFocusPathChange: (path: readonly string[]) => void;
|
||||
readonly motionDisabled: boolean;
|
||||
};
|
||||
|
||||
const DiscussionLinks = ({
|
||||
sceneId,
|
||||
openDiscussion,
|
||||
}: {
|
||||
readonly sceneId: string;
|
||||
readonly openDiscussion: (branchId: string) => void;
|
||||
}) => {
|
||||
const branches = discussionBranches.filter((branch) => branch.parentSceneId === sceneId);
|
||||
if (branches.length === 0) return null;
|
||||
return (
|
||||
<div className="scene-body__discussion-links" aria-label="discussion topics">
|
||||
{branches.map((branch) => (
|
||||
<button
|
||||
key={branch.id}
|
||||
type="button"
|
||||
onClick={() => openDiscussion(branch.id)}
|
||||
>
|
||||
{branch.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
|
||||
<>
|
||||
<StageCaption eyebrow={`Act ${scene.stageTheme === "paper" ? "I" : "II"} · ${scene.claimClass}`} title={scene.title}>
|
||||
@@ -176,12 +208,14 @@ const assertNever = (value: never): never => {
|
||||
throw new Error(`Unexpected view: ${value}`);
|
||||
};
|
||||
|
||||
export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvidence, onFocusPathChange, motionDisabled }: SceneBodyProps) => {
|
||||
export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvidence, openDiscussion, onFocusPathChange, motionDisabled }: SceneBodyProps) => {
|
||||
const sceneId = location.kind === "main" ? location.sceneId : "positioning";
|
||||
const beatId = location.kind === "main" ? location.beatId : "landscape";
|
||||
const scene = findScene(sceneId) ?? findScene("thesis")!;
|
||||
const beat = findBeat(sceneId, beatId) ?? scene.beats[0]!;
|
||||
|
||||
const discussionLinks = <DiscussionLinks sceneId={scene.id} openDiscussion={openDiscussion} />;
|
||||
const content = (() => {
|
||||
switch (scene.view) {
|
||||
case "narrative":
|
||||
return <NarrativeScene scene={scene} beat={beat} />;
|
||||
@@ -224,4 +258,12 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvid
|
||||
default:
|
||||
return assertNever(scene.view);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{discussionLinks}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { m } from "motion/react";
|
||||
import { useId } from "react";
|
||||
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
|
||||
|
||||
export type PresentationNode = {
|
||||
@@ -54,8 +55,13 @@ export const WorkflowGraphStage = ({
|
||||
execution,
|
||||
selectedNodeId,
|
||||
selectNode,
|
||||
}: WorkflowGraphStageProps) => (
|
||||
<div className="workflow-graph-stage" role="group" aria-label="workflow graph">
|
||||
}: WorkflowGraphStageProps) => {
|
||||
const markerPrefix = useId().replaceAll(":", "");
|
||||
const arrowMarkerId = `${markerPrefix}-workflow-arrow`;
|
||||
const activeArrowMarkerId = `${markerPrefix}-workflow-arrow-active`;
|
||||
|
||||
return (
|
||||
<div className="workflow-graph-stage" role="group" aria-label="workflow graph">
|
||||
<div className="workflow-graph-stage__legend" aria-hidden="true">
|
||||
<span><i data-state="completed" />Completed</span>
|
||||
<span><i data-state="current" />Current</span>
|
||||
@@ -64,10 +70,10 @@ export const WorkflowGraphStage = ({
|
||||
|
||||
<svg className="workflow-graph-stage__connectors" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="workflow-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<marker className="workflow-graph-stage__arrow-marker" id={arrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" />
|
||||
</marker>
|
||||
<marker id="workflow-arrow-active" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<marker className="workflow-graph-stage__arrow-marker--active" id={activeArrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" />
|
||||
</marker>
|
||||
</defs>
|
||||
@@ -84,7 +90,7 @@ export const WorkflowGraphStage = ({
|
||||
y1={`${from.y}%`}
|
||||
x2={`${to.x}%`}
|
||||
y2={`${to.y}%`}
|
||||
markerEnd={active ? "url(#workflow-arrow-active)" : "url(#workflow-arrow)"}
|
||||
markerEnd={active ? `url(#${activeArrowMarkerId})` : `url(#${arrowMarkerId})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -127,5 +133,6 @@ export const WorkflowGraphStage = ({
|
||||
</m.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export const FigureNodeView = ({
|
||||
<span className="figure-node__kind">{kindLabel[node.kind] ?? node.kind}</span>
|
||||
<strong className="figure-node__label">{node.label}</strong>
|
||||
<span className="figure-node__summary">{node.summary}</span>
|
||||
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">▸</span>}
|
||||
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">▸</span>}
|
||||
{isActive && <span className="figure-node__current-marker">Current</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,14 @@ describe("InteractiveFigure", () => {
|
||||
expect(container.querySelector(".react-flow__handle-bottom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps one node tabbable when no node is marked current", () => {
|
||||
renderFigure({ focusPath: [], activeNodeId: null });
|
||||
|
||||
expect(figureNode("client")).toHaveAttribute("tabindex", "0");
|
||||
expect(figureNode("runtime")).toHaveAttribute("tabindex", "-1");
|
||||
expect(figureNode("leaf")).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("uses left and right handles for flow figures", () => {
|
||||
const flowCatalog: FigureCatalogDefinition = {
|
||||
...validCatalog,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
|
||||
@@ -29,6 +29,7 @@ type FigureNodeData = {
|
||||
readonly kind: FigureNodeKind;
|
||||
readonly orientation: "horizontal" | "vertical";
|
||||
readonly isActive: boolean;
|
||||
readonly isFocused: boolean;
|
||||
readonly isExpandable: boolean;
|
||||
readonly onActivate: (nodeId: string) => void;
|
||||
readonly onExpand: (nodeId: string) => void;
|
||||
@@ -51,7 +52,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
|
||||
data-expandable={expandable}
|
||||
data-testid={`figure-node-${data.nodeId}`}
|
||||
aria-label={accessibleName}
|
||||
tabIndex={data.isActive ? 0 : -1}
|
||||
tabIndex={data.isFocused ? 0 : -1}
|
||||
onClick={() => {
|
||||
data.onActivate(data.nodeId);
|
||||
if (expandable) data.onExpand(data.nodeId);
|
||||
@@ -66,7 +67,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
|
||||
<span className="figure-node__kind">{data.kind}</span>
|
||||
<strong className="figure-node__label">{data.label}</strong>
|
||||
<span className="figure-node__summary">{data.summary}</span>
|
||||
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">▸</span>}
|
||||
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">▸</span>}
|
||||
{data.isActive && <span className="figure-node__current-marker">Current</span>}
|
||||
</button>
|
||||
<Handle type="source" position={sourcePosition} id="source" />
|
||||
@@ -100,12 +101,18 @@ const InteractiveFigureInner = ({
|
||||
);
|
||||
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
|
||||
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
|
||||
const focusedNodeIdRef = useRef(initialFocusedNodeId);
|
||||
|
||||
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
|
||||
focusedNodeIdRef.current = fallbackFocusedNodeId;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!fallbackFocusedNodeId) return;
|
||||
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
|
||||
focusedNodeIdRef.current = fallbackFocusedNodeId;
|
||||
setFocusedNodeId(fallbackFocusedNodeId);
|
||||
}
|
||||
}, [activeNodeId, fallbackFocusedNodeId, layout.nodes]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
(nodeId: string) => {
|
||||
@@ -150,6 +157,7 @@ const InteractiveFigureInner = ({
|
||||
event.stopPropagation();
|
||||
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
|
||||
focusedNodeIdRef.current = nextId;
|
||||
setFocusedNodeId(nextId);
|
||||
const nextNode = containerRef.current?.querySelector(
|
||||
`[data-testid="figure-node-${nextId}"]`,
|
||||
);
|
||||
@@ -161,6 +169,7 @@ const InteractiveFigureInner = ({
|
||||
|
||||
const handleActivateNode = useCallback((nodeId: string) => {
|
||||
focusedNodeIdRef.current = nodeId;
|
||||
setFocusedNodeId(nodeId);
|
||||
}, []);
|
||||
|
||||
const rfNodes: Node[] = useMemo(
|
||||
@@ -176,12 +185,13 @@ const InteractiveFigureInner = ({
|
||||
kind: node.kind,
|
||||
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
|
||||
isActive: node.id === activeNodeId,
|
||||
isFocused: node.id === focusedNodeId,
|
||||
isExpandable: node.childFigureId !== undefined,
|
||||
onActivate: handleActivateNode,
|
||||
onExpand: handleExpand,
|
||||
},
|
||||
})),
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, focusedNodeId, handleActivateNode, handleExpand],
|
||||
);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
disconnectedCyclicCatalog,
|
||||
duplicateFigureCatalog,
|
||||
duplicateNodeCatalog,
|
||||
explicitFigureMissingPosition,
|
||||
unknownChildCatalog,
|
||||
unknownEdgeCatalog,
|
||||
unknownRootCatalog,
|
||||
@@ -27,4 +28,11 @@ describe("defineFigureCatalog", () => {
|
||||
])("rejects %s", (_label, catalog, code) => {
|
||||
expect(() => defineFigureCatalog(catalog)).toThrow(code);
|
||||
});
|
||||
|
||||
it("rejects explicit layouts missing a node position", () => {
|
||||
expect(() => defineFigureCatalog({
|
||||
rootFigureId: explicitFigureMissingPosition.id,
|
||||
figures: [explicitFigureMissingPosition],
|
||||
})).toThrow("missing_explicit_position:explicit-missing:runtime");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FigureCatalogIssue =
|
||||
| { readonly code: "unknown_root_figure"; readonly figureId: string }
|
||||
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
|
||||
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
|
||||
| { readonly code: "missing_explicit_position"; readonly figureId: string; readonly nodeId: string }
|
||||
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
|
||||
|
||||
const issueToCode = (issue: FigureCatalogIssue): string => {
|
||||
@@ -23,6 +24,8 @@ const issueToCode = (issue: FigureCatalogIssue): string => {
|
||||
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
|
||||
case "unknown_child_figure":
|
||||
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
|
||||
case "missing_explicit_position":
|
||||
return `missing_explicit_position:${issue.figureId}:${issue.nodeId}`;
|
||||
case "child_cycle":
|
||||
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
|
||||
}
|
||||
@@ -87,6 +90,15 @@ export const defineFigureCatalog = (
|
||||
}
|
||||
}
|
||||
|
||||
for (const figure of catalog.figures) {
|
||||
if (figure.layout.kind !== "explicit") continue;
|
||||
for (const node of figure.nodes) {
|
||||
if (figure.layout.positions[node.id] === undefined) {
|
||||
issues.push({ code: "missing_explicit_position", figureId: figure.id, nodeId: node.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect child-figure cycles from every figure, not just the root, so
|
||||
// disconnected subgraphs with cycles are also caught.
|
||||
const edgeVisited = new Set<string>();
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.figure-node__expand-affance {
|
||||
.figure-node__expand-affordance {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compositionForState,
|
||||
createInitialPresentationState,
|
||||
initialPresentationState,
|
||||
presentationReducer,
|
||||
} from "./presentation-state.js";
|
||||
@@ -48,6 +49,28 @@ describe("presentationReducer", () => {
|
||||
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] });
|
||||
});
|
||||
|
||||
it("clears evidence overrides when deep-linking into a discussion branch", () => {
|
||||
const state = presentationReducer(
|
||||
{
|
||||
...initialPresentationState,
|
||||
evidencePresentationOverride: "inspector",
|
||||
},
|
||||
{
|
||||
type: "jump_hash",
|
||||
hash: "#discuss/hosted-automation",
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.location).toEqual({ kind: "discussion", branchId: "hosted-automation" });
|
||||
expect(state.evidencePresentationOverride).toBeNull();
|
||||
expect(state.discussionReturn).toEqual({ kind: "main", sceneId: "positioning", beatId: "landscape", focusPath: [] });
|
||||
});
|
||||
|
||||
it("creates fresh startedAt values per reducer session", () => {
|
||||
expect(createInitialPresentationState(100).startedAt).toBe(100);
|
||||
expect(createInitialPresentationState(250).startedAt).toBe(250);
|
||||
});
|
||||
|
||||
it("opens a discussion branch and returns to the originating beat", () => {
|
||||
const positioned = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
@@ -72,6 +95,18 @@ describe("presentationReducer", () => {
|
||||
expect(state.selectedNodeId).toBe("review_issues");
|
||||
});
|
||||
|
||||
it("clears node detail through nullable selection", () => {
|
||||
const withNode = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
nodeId: "review_issues",
|
||||
});
|
||||
const cleared = presentationReducer(withNode, {
|
||||
type: "select_node",
|
||||
nodeId: null,
|
||||
});
|
||||
expect(cleared.selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overlays in priority order: inspector, node, discussion", () => {
|
||||
const withNode = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
|
||||
@@ -29,11 +29,11 @@ export type PresentationState = {
|
||||
export type PresentationAction =
|
||||
| { readonly type: "next" }
|
||||
| { readonly type: "previous" }
|
||||
| { readonly type: "jump"; readonly location: PresentationLocation }
|
||||
| { readonly type: "jump"; readonly location: MainLocation }
|
||||
| { readonly type: "jump_hash"; readonly hash: string }
|
||||
| { readonly type: "open_discussion"; readonly branchId: string }
|
||||
| { readonly type: "close_discussion" }
|
||||
| { readonly type: "select_node"; readonly nodeId: string }
|
||||
| { readonly type: "select_node"; readonly nodeId: string | null }
|
||||
| { readonly type: "clear_node" }
|
||||
| { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation }
|
||||
| { readonly type: "close_overlay" }
|
||||
@@ -41,15 +41,17 @@ export type PresentationAction =
|
||||
| { readonly type: "set_focus_path"; readonly path: readonly string[] }
|
||||
| { readonly type: "toggle_motion" };
|
||||
|
||||
export const initialPresentationState: PresentationState = {
|
||||
export const createInitialPresentationState = (startedAt = Date.now()): PresentationState => ({
|
||||
location: defaultMainLocation,
|
||||
discussionReturn: null,
|
||||
selectedNodeId: null,
|
||||
evidencePresentationOverride: null,
|
||||
playbackMode: "replay",
|
||||
motionDisabled: false,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
startedAt,
|
||||
});
|
||||
|
||||
export const initialPresentationState: PresentationState = createInitialPresentationState();
|
||||
|
||||
const compositionForLocation = (
|
||||
location: PresentationLocation,
|
||||
@@ -130,7 +132,11 @@ export const presentationReducer = (
|
||||
const returnLoc = branch
|
||||
? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation
|
||||
: defaultMainLocation;
|
||||
return { ...moveToLocation(state, parsed), discussionReturn: returnLoc };
|
||||
return {
|
||||
...moveToLocation(state, parsed),
|
||||
discussionReturn: returnLoc,
|
||||
evidencePresentationOverride: null,
|
||||
};
|
||||
}
|
||||
case "open_discussion": {
|
||||
const branch = findDiscussionBranch(action.branchId);
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
|
||||
.presentation-stage__primary {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
@@ -102,6 +103,33 @@
|
||||
color: oklch(0.72 0.03 250);
|
||||
}
|
||||
|
||||
.scene-body__discussion-links {
|
||||
position: absolute;
|
||||
left: 2rem;
|
||||
bottom: 1.4rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
max-width: min(52rem, calc(100% - 4rem));
|
||||
}
|
||||
|
||||
.scene-body__discussion-links button {
|
||||
border: 1px solid oklch(0.82 0.04 82 / 0.22);
|
||||
border-radius: 999px;
|
||||
background: oklch(0.16 0.018 65 / 0.74);
|
||||
color: oklch(0.9 0.025 82);
|
||||
padding: 0.28rem 0.7rem;
|
||||
font: 600 0.72rem/1 var(--font-interface);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scene-body__discussion-links button:hover,
|
||||
.scene-body__discussion-links button:focus-visible {
|
||||
border-color: var(--accent-cyan);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.operator-chat {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { StageCaption } from "../StageCaption.js";
|
||||
import { InteractiveFigure } from "../figures/InteractiveFigure.js";
|
||||
import { architectureCatalog } from "../figures/architecture-catalog.js";
|
||||
import { ARCHITECTURE_CATALOG_ID, architectureCatalog } from "../figures/architecture-catalog.js";
|
||||
import type { SceneDefinition, SceneBeatDefinition } from "../storyboard.js";
|
||||
|
||||
const architectureCatalogs = {
|
||||
[ARCHITECTURE_CATALOG_ID]: architectureCatalog,
|
||||
} as const;
|
||||
|
||||
type ArchitectureSceneProps = {
|
||||
readonly scene: SceneDefinition;
|
||||
readonly beat: SceneBeatDefinition;
|
||||
@@ -19,18 +23,26 @@ export const ArchitectureScene = ({
|
||||
activeNodeId,
|
||||
onFocusPathChange,
|
||||
motionDisabled,
|
||||
}: ArchitectureSceneProps) => (
|
||||
<section className="architecture-scene" data-testid="architecture-scene">
|
||||
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<InteractiveFigure
|
||||
catalog={architectureCatalog}
|
||||
focusPath={focusPath}
|
||||
activeNodeId={activeNodeId}
|
||||
onFocusPathChange={onFocusPathChange}
|
||||
motionDisabled={motionDisabled}
|
||||
size="wide"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}: ArchitectureSceneProps) => {
|
||||
// Only one catalog ships today, but resolving through the authored catalog id
|
||||
// keeps beat metadata honest and makes the next catalog addition localized.
|
||||
const catalog = beat.figure
|
||||
? architectureCatalogs[beat.figure.catalogId as keyof typeof architectureCatalogs] ?? architectureCatalog
|
||||
: architectureCatalog;
|
||||
|
||||
return (
|
||||
<section className="architecture-scene" data-testid="architecture-scene">
|
||||
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<InteractiveFigure
|
||||
catalog={catalog}
|
||||
focusPath={focusPath}
|
||||
activeNodeId={activeNodeId}
|
||||
onFocusPathChange={onFocusPathChange}
|
||||
motionDisabled={motionDisabled}
|
||||
size="wide"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,6 +247,13 @@ export type DiscussionBranchDefinition = {
|
||||
readonly claimClass: ClaimClass;
|
||||
readonly evidencePointer: string;
|
||||
readonly summary: string;
|
||||
readonly detail?: {
|
||||
readonly text: string;
|
||||
readonly links?: ReadonlyArray<{
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const defineDiscussionBranches = <const Branches extends readonly DiscussionBranchDefinition[]>(
|
||||
@@ -277,6 +284,9 @@ export const discussionBranches = defineDiscussionBranches([
|
||||
claimClass: "future-work",
|
||||
evidencePointer: "Thesis: Workflow Automation Platforms and Future Work",
|
||||
summary: "Hosted triggers and scheduling are mature elsewhere and remain future work here.",
|
||||
detail: {
|
||||
text: "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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "durable-agent-graphs",
|
||||
@@ -293,6 +303,19 @@ export const discussionBranches = defineDiscussionBranches([
|
||||
claimClass: "external-context",
|
||||
evidencePointer: "Thesis: Model Context Protocol; Anthropic MCP; Cloudflare Code Mode",
|
||||
summary: "MCP is a capability protocol; progressive discovery addresses large agent-facing surfaces.",
|
||||
detail: {
|
||||
text: "Both are external context.",
|
||||
links: [
|
||||
{
|
||||
label: "Anthropic MCP",
|
||||
href: "https://www.anthropic.com/engineering/code-execution-with-mcp",
|
||||
},
|
||||
{
|
||||
label: "Cloudflare Code Mode",
|
||||
href: "https://blog.cloudflare.com/code-mode-mcp/",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lifecycle-states",
|
||||
|
||||
@@ -385,11 +385,11 @@
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
#workflow-arrow polygon {
|
||||
.workflow-graph-stage__arrow-marker polygon {
|
||||
fill: oklch(0.48 0.035 250);
|
||||
}
|
||||
|
||||
#workflow-arrow-active polygon {
|
||||
.workflow-graph-stage__arrow-marker--active polygon {
|
||||
fill: var(--accent-cyan);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user