fix: address presentation review findings

This commit is contained in:
lda
2026-07-08 06:23:58 +07:00 Verified
parent eb40c951d0
commit 20bf11dde1
29 changed files with 717 additions and 122 deletions
+9 -4
View File
@@ -108,12 +108,17 @@ Implementation order:
[`adaptive presentation canvas and evidence inspector`](superpowers/specs/2026-07-05-adaptive-presentation-canvas-design.md). [`adaptive presentation canvas and evidence inspector`](superpowers/specs/2026-07-05-adaptive-presentation-canvas-design.md).
Implementation: Implementation:
[`adaptive presentation canvas plan`](historical/superpowers/plans/2026-07-06-adaptive-presentation-canvas.md). [`adaptive presentation canvas plan`](historical/superpowers/plans/2026-07-06-adaptive-presentation-canvas.md).
13. Then: adopt source-owned AI Elements chat primitives against existing 13. Completed: address the presentation CodeRabbit review pass covering
reducer state semantics, agent approval cleanup, discussion modal
accessibility, figure validation, keyboard roving focus, and stale demo
agent spec wording. Implementation:
[`presentation CodeRabbit fixes`](historical/superpowers/plans/2026-07-08-presentation-coderabbit-fixes.md).
14. Then: adopt source-owned AI Elements chat primitives against existing
`AgentMessagePart` / `AgentDriver` contracts. `AgentMessagePart` / `AgentDriver` contracts.
14. Future: implement Schema Form Surface and synchronized Approval Session. 15. Future: implement Schema Form Surface and synchronized Approval Session.
15. Future: implement Guided Run Beat Gates, presenter companion, Scene 10 16. Future: implement Guided Run Beat Gates, presenter companion, Scene 10
product graph, final scene visuals, evidence assets, and rehearsal timing. product graph, final scene visuals, evidence assets, and rehearsal timing.
16. Add a static slide/appendix shell only after presentation mode is clear. 17. Add a static slide/appendix shell only after presentation mode is clear.
Astro remains an option, not the default next surface. Astro remains an option, not the default next surface.
Boundaries: this is not a production admin panel, generic visual workflow Boundaries: this is not a production admin panel, generic visual workflow
@@ -0,0 +1,166 @@
# Presentation CodeRabbit Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve still-valid CodeRabbit findings from `random shit/rabbitreview/pih.txt` without mixing correctness fixes with broader visual redesign.
**Architecture:** Treat the presentation as three independent surfaces: reducer/state correctness, agent/timeline lifecycle correctness, and presentational accessibility/figure data integrity. Keep the first slice small enough to test and commit safely; defer larger chat/visual/data-model cleanup into follow-up slices.
**Tech Stack:** React 19, TypeScript, Vite/Vitest, React Flow, Motion, Valibot, Effect-backed RPC package, Markdown docs.
## Global Constraints
- Work from repo root unless a command explicitly uses `--dir web`.
- Before editing docs, follow `docs/AGENTS.md`; completed plans move to `docs/historical/superpowers/plans/`.
- Keep changes minimal: fix only verified current issues from the review.
- Use tests for behavior changes; do not rely on visual inspection for reducer or hook semantics.
- Do not duplicate canvas/container CSS if `styles/editorial.css` already owns the wrapper setup.
---
## Execution Status
Completed on 2026-07-08. The implementation fixed the still-valid correctness, accessibility, lifecycle, figure-validation, and stale-doc findings. Deferred items are intentionally not hidden:
- `PresentationCanvas` container findings were stale; `styles/editorial.css` already owns the positioned viewport, absolute canvas, transform origin, and `presentation-canvas` container.
- Full reuse of `FigureNodeView` inside React Flow nodes was deferred; the accessibility behavior is now covered by tests, and the component split can be handled in a later refactor.
- Broader scene visual redesign remains separate from review-fix work.
### Task 1: Correctness And Accessibility Fixes
**Files:**
- Modify: `web/apps/console/src/presentation/presentation-state.ts`
- Modify: `web/apps/console/src/presentation/presentation-state.test.ts`
- Modify: `web/apps/console/src/presentation/PresentationRoute.tsx`
- Modify: `web/apps/console/src/presentation/PresentationStage.tsx`
- Modify: `web/apps/console/src/presentation/SceneBody.tsx`
- Modify: `web/apps/console/src/presentation/DemoWorkflowScene.tsx`
- Modify: `web/apps/console/src/demo/agent/useDemoAgent.ts`
- Modify: `web/apps/console/src/demo/agent/useDemoAgent.test.tsx`
- Modify: `web/apps/console/src/presentation/DiscussionPanel.tsx`
- Modify: `web/apps/console/src/presentation/DiscussionPanel.test.tsx`
**Interfaces:**
- Produces: `createInitialPresentationState(): PresentationState`
- Produces: `selectNode(nodeId: string | null): void` throughout presentation components
- Produces: modal keyboard behavior in `DiscussionPanel`
- [x] **Step 1: Add failing reducer tests**
Add tests proving `jump_hash` into `#discuss/...` clears `evidencePresentationOverride`, `select_node` accepts `null`, and fresh state uses a new `startedAt`.
- [x] **Step 2: Update reducer types and initialization**
Narrow `jump` to `MainLocation`, add `createInitialPresentationState`, allow nullable `select_node`, and clear evidence override for discussion deep links.
- [x] **Step 3: Add failing agent lifecycle tests**
Add tests proving unmount/reset aborts a pending approval and that normal approval resolution removes the abort listener lifecycle.
- [x] **Step 4: Harden `useDemoAgent` approval cleanup**
Track the active approval request as one object with `resolve`, `reject`, `signal`, and `abortHandler`; only clear refs for the matching active request.
- [x] **Step 5: Add modal behavior to `DiscussionPanel`**
Align with `EvidenceInspector`: `aria-modal`, initial focus, focus trap, Escape close, and focus restoration.
- [x] **Step 6: Run focused tests and commit**
Run:
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/presentation-state.test.ts src/demo/agent/useDemoAgent.test.tsx src/presentation/DiscussionPanel.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Commit message: `fix: harden presentation state and agent lifecycle`.
### Task 2: Figure Data And Keyboard Integrity
**Files:**
- Modify: `web/apps/console/src/presentation/figures/catalog.ts`
- Modify: `web/apps/console/src/presentation/figures/catalog.test.ts`
- Modify: `web/apps/console/src/presentation/figures/InteractiveFigure.tsx`
- Modify: `web/apps/console/src/presentation/figures/InteractiveFigure.test.tsx`
- Modify: `web/apps/console/src/presentation/figures/FigureNodeView.tsx`
- Modify: `web/apps/console/src/presentation/figures/interactive-figure.css`
- Modify: `web/apps/console/src/presentation/scenes/ArchitectureScene.tsx`
- Modify: `web/apps/console/src/presentation/scenes/ArchitectureScene.test.tsx`
**Interfaces:**
- Produces: catalog issue `missing_explicit_position`
- Produces: roving tab order based on focused node, not active marker
- [x] **Step 1: Validate explicit layout positions**
Add a catalog test for an explicit layout missing one node position, then add `missing_explicit_position` to `FigureCatalogIssue` and `issueToCode`.
- [x] **Step 2: Fix figure keyboard entry**
Add a test that a figure with `activeNodeId={null}` has exactly one tabbable node. Drive `tabIndex` from focused-node state and fall back to the first node.
- [x] **Step 3: Fix affordance selector typo**
Rename `figure-node__expand-affance` to `figure-node__expand-affordance` in both CSS and renderers.
- [x] **Step 4: Resolve architecture catalog by beat metadata**
Use `beat.figure?.catalogId` to select the catalog. If no known catalog matches, render the architecture catalog as a safe fallback.
- [x] **Step 5: Run focused tests and commit**
Run:
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/figures/catalog.test.ts src/presentation/figures/InteractiveFigure.test.tsx src/presentation/scenes/ArchitectureScene.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Commit message: `fix: validate presentation figures`.
### Task 3: Docs And Deferred Cleanup
**Files:**
- Modify: `docs/superpowers/specs/2026-07-03-constrained-demo-agent-design.md`
- Modify: `web/apps/console/src/demo/agent/recipes.ts`
- Modify: `web/apps/console/src/demo/agent/preparedRecipeDriver.ts`
- Modify: `web/apps/console/src/demo/useDemoTimeline.ts`
- Modify: `web/apps/console/src/presentation/DiscussionPanel.tsx`
- Modify: `web/apps/console/src/presentation/OperatorChat.test.tsx`
- Modify: `web/apps/console/src/presentation/WorkflowGraphStage.tsx`
**Interfaces:**
- Produces: docs that use `EvidenceInspector`, not `EvidenceDrawer`
- Produces: prepared recipe step inputs instead of hardcoded `review_issues`
- Produces: unique SVG marker IDs in `WorkflowGraphStage`
- [x] **Step 1: Refresh stale spec terms**
Rename `EvidenceDrawer` to `EvidenceInspector` in the flow diagram and update the prepared-recipe section to say `requestApproval` gates the typed review step.
- [x] **Step 2: Remove small data hardcodes**
Derive recipe tool names from shared tool types, move the selected workflow node into recipe step data, and render discussion branch details from branch data when that model is ready.
- [x] **Step 3: Add missing branch tests**
Add `OperatorChat` tests for approval, error, presentation action, prepared handoff, and fallback messages.
- [x] **Step 4: Run full web verification and archive plan**
Run:
```bash
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
git diff --check
```
Move this plan to `docs/historical/superpowers/plans/` and commit.
## Review Notes
- Canvas/container findings are stale if `web/apps/console/src/presentation/styles/editorial.css` remains imported by the presentation route; it already provides positioned viewport, absolute canvas, transform origin, and `container-name: presentation-canvas`.
- The visual quality of scenes 6, 7, and 10 is not solved by this review-fix plan. Treat those as a separate presentation design pass.
@@ -72,7 +72,7 @@ type AgentDriver = {
readonly run: ( readonly run: (
input: AgentRunInput, input: AgentRunInput,
signal: AbortSignal, signal: AbortSignal,
requestApproval: () => Promise<AgentApproval>, requestApproval: (signal: AbortSignal) => Promise<AgentApproval>,
) => AsyncIterable<AgentMessage>; ) => AsyncIterable<AgentMessage>;
}; };
``` ```
@@ -80,8 +80,9 @@ type AgentDriver = {
The first implementation only ships the `prepared-recipe` driver. It produces The first implementation only ships the `prepared-recipe` driver. It produces
events from a hard-coded recipe, calls the existing demo timeline execution events from a hard-coded recipe, calls the existing demo timeline execution
path, and emits presentation actions through the same event stream. The path, and emits presentation actions through the same event stream. The
prepared driver ignores the `requestApproval` callback (deterministic, no prepared driver uses `requestApproval` at the recipe's typed review step, pauses
approval needed). until the operator approves or cancels the resume, then continues or exits the
recipe.
The `ai-sdk` kind is reserved for a future driver. The boundary should be shaped The `ai-sdk` kind is reserved for a future driver. The boundary should be shaped
so a server-side Vercel AI SDK integration can map `streamText` parts into the so a server-side Vercel AI SDK integration can map `streamText` parts into the
@@ -247,7 +248,7 @@ PresentationRoute
-> PreparedRecipeDriver -> PreparedRecipeDriver
-> PresentationToolAdapter -> selected node / beat / evidence state -> PresentationToolAdapter -> selected node / beat / evidence state
-> AgentMessage[] -> AgentMessage[]
-> StandardChat / OperationBlock / EvidenceDrawer / DemoTimeline -> StandardChat / OperationBlock / EvidenceInspector / DemoTimeline
``` ```
The prepared driver reads from the reviewed recording and emits agent events The prepared driver reads from the reviewed recording and emits agent events
@@ -256,9 +257,9 @@ to the evidence surface.
In live mode, the driver calls the connected workflow server through the In live mode, the driver calls the connected workflow server through the
existing RPC operation path. Live mode is deferred to a future slice; the existing RPC operation path. Live mode is deferred to a future slice; the
prepared driver is replay-only for now. The `AgentDriver` interface includes `AgentDriver` interface includes `requestApproval` so both the prepared driver
`requestApproval` for future live-mode approval, but the prepared driver and a future live driver can pause at operator-controlled workflow resume
currently ignores it. boundaries.
## Future AI SDK Path ## Future AI SDK Path
@@ -65,9 +65,9 @@ export async function* runPreparedRecipeReplay(
id: step.id, id: step.id,
role: "assistant", role: "assistant",
parts: [ parts: [
agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: "review_issues" }), agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: step.toolInput.nodeId }),
presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" }), presentationActionPart({ type: "selectWorkflowNode", nodeId: step.toolInput.nodeId }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: "review_issues" }), agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: step.toolInput.nodeId }),
], ],
}; };
break; break;
+15 -7
View File
@@ -1,18 +1,25 @@
import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js"; import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
import type { PresentationToolName, WorkflowToolName } from "./tools.js";
export type RecipeTool = export type RecipeTool =
| "inspectDeployment" | Extract<WorkflowToolName, "inspectDeployment" | "startPreparedReportRun" | "resumeIssueReview" | "readRunTrace">
| "startPreparedReportRun" | Extract<PresentationToolName, "selectWorkflowNode">;
| "selectWorkflowNode"
| "resumeIssueReview"
| "readRunTrace";
export type PreparedRecipeStep = { type SelectWorkflowNodeStep = {
readonly id: string; readonly id: string;
readonly narration: string; readonly narration: string;
readonly toolName: RecipeTool | null; readonly toolName: "selectWorkflowNode";
readonly toolInput: { readonly nodeId: string };
}; };
type OtherPreparedRecipeStep = {
readonly id: string;
readonly narration: string;
readonly toolName: Exclude<RecipeTool, "selectWorkflowNode"> | null;
};
export type PreparedRecipeStep = SelectWorkflowNodeStep | OtherPreparedRecipeStep;
export type PreparedRecipe = { export type PreparedRecipe = {
readonly id: "prepare-thesis-report"; readonly id: "prepare-thesis-report";
readonly title: string; readonly title: string;
@@ -46,6 +53,7 @@ export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
id: "focus-interrupt", id: "focus-interrupt",
narration: "Let's zoom into the typed issue-review interrupt.", narration: "Let's zoom into the typed issue-review interrupt.",
toolName: "selectWorkflowNode", toolName: "selectWorkflowNode",
toolInput: { nodeId: "review_issues" },
}, },
{ {
id: "resume", id: "resume",
@@ -123,6 +123,32 @@ describe("useDemoAgent", () => {
expect(result.current.pendingActions).toEqual([]); expect(result.current.pendingActions).toEqual([]);
}); });
it("aborts a pending approval when the hook unmounts", async () => {
let observedSignal: AbortSignal | null = null;
const driver: AgentDriver = {
kind: "prepared-recipe",
run: async function* (_input, signal, requestApproval) {
observedSignal = signal;
yield {
id: "approval-msg",
role: "assistant",
parts: [{ type: "approval-request", callId: "call-1", name: "resumeIssueReview", prompt: "Approve?" }],
};
await requestApproval(signal);
},
};
const { result, unmount } = renderHook(() => useDemoAgent(driver));
act(() => result.current.startPreparedReplay());
await waitFor(() => {
expect(result.current.phase).toBe("awaiting-approval");
});
unmount();
expect((observedSignal as AbortSignal | null)?.aborted).toBe(true);
});
it("denial halts the driver and clears awaiting-approval", async () => { it("denial halts the driver and clears awaiting-approval", async () => {
const driver = createFakeApprovalDriver(async () => ({ approved: false, comment: "nope" })); const driver = createFakeApprovalDriver(async () => ({ approved: false, comment: "nope" }));
const { result } = renderHook(() => useDemoAgent(driver)); const { result } = renderHook(() => useDemoAgent(driver));
+47 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentApproval, AgentDriver, AgentMessage, PresentationToolAction } from "./events.js"; import type { AgentApproval, AgentDriver, AgentMessage, PresentationToolAction } from "./events.js";
export type DemoAgentPhase = "idle" | "running" | "awaiting-approval" | "completed" | "failed"; export type DemoAgentPhase = "idle" | "running" | "awaiting-approval" | "completed" | "failed";
@@ -16,62 +16,84 @@ export type DemoAgentController = {
const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> => const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> =>
message.parts.flatMap((part) => part.type === "presentation-action" ? [part.action] : []); message.parts.flatMap((part) => part.type === "presentation-action" ? [part.action] : []);
type PendingApproval = {
readonly resolve: (decision: AgentApproval) => void;
readonly reject: (error: Error) => void;
readonly signal: AbortSignal;
readonly abortHandler: () => void;
};
export const useDemoAgent = (driver: AgentDriver): DemoAgentController => { export const useDemoAgent = (driver: AgentDriver): DemoAgentController => {
const [phase, setPhase] = useState<DemoAgentPhase>("idle"); const [phase, setPhase] = useState<DemoAgentPhase>("idle");
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([]); const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([]);
const [pendingActions, setPendingActions] = useState<ReadonlyArray<PresentationToolAction>>([]); const [pendingActions, setPendingActions] = useState<ReadonlyArray<PresentationToolAction>>([]);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const approvalResolveRef = useRef<((decision: AgentApproval) => void) | null>(null); const pendingApprovalRef = useRef<PendingApproval | null>(null);
const approvalRejectRef = useRef<((error: Error) => void) | null>(null);
const clearPendingApproval = useCallback((pending: PendingApproval | null) => {
if (pending === null || pendingApprovalRef.current !== pending) return;
pending.signal.removeEventListener("abort", pending.abortHandler);
pendingApprovalRef.current = null;
}, []);
const abortPendingApproval = useCallback((reason: string) => {
const pending = pendingApprovalRef.current;
if (!pending) return;
clearPendingApproval(pending);
pending.reject(new DOMException(reason, "AbortError"));
}, [clearPendingApproval]);
const reset = useCallback(() => { const reset = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = null; abortRef.current = null;
if (approvalRejectRef.current) { abortPendingApproval("Agent reset while awaiting approval");
approvalRejectRef.current(new DOMException("Agent reset while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
}
setPhase("idle"); setPhase("idle");
setMessages([]); setMessages([]);
setPendingActions([]); setPendingActions([]);
}, []); }, [abortPendingApproval]);
const clearPendingActions = useCallback(() => { const clearPendingActions = useCallback(() => {
setPendingActions([]); setPendingActions([]);
}, []); }, []);
const submitApproval = useCallback((decision: AgentApproval) => { const submitApproval = useCallback((decision: AgentApproval) => {
if (approvalResolveRef.current) { const pending = pendingApprovalRef.current;
approvalResolveRef.current(decision); if (!pending) return;
approvalResolveRef.current = null; clearPendingApproval(pending);
approvalRejectRef.current = null; pending.resolve(decision);
setPhase("running"); setPhase("running");
} }, [clearPendingApproval]);
}, []);
const requestApproval = useCallback((signal: AbortSignal): Promise<AgentApproval> => { const requestApproval = useCallback((signal: AbortSignal): Promise<AgentApproval> => {
setPhase("awaiting-approval"); setPhase("awaiting-approval");
return new Promise<AgentApproval>((resolve, reject) => { return new Promise<AgentApproval>((resolve, reject) => {
approvalResolveRef.current = resolve;
approvalRejectRef.current = reject;
if (signal.aborted) { if (signal.aborted) {
reject(new DOMException("Agent aborted while awaiting approval", "AbortError")); reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
return; return;
} }
const onAbort = () => { const onAbort = () => {
signal.removeEventListener("abort", onAbort); const pending = pendingApprovalRef.current;
if (!pending || pending.abortHandler !== onAbort) return;
clearPendingApproval(pending);
reject(new DOMException("Agent aborted while awaiting approval", "AbortError")); reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
}; };
const pending: PendingApproval = {
resolve,
reject,
signal,
abortHandler: onAbort,
};
pendingApprovalRef.current = pending;
signal.addEventListener("abort", onAbort); signal.addEventListener("abort", onAbort);
}); });
}, []); }, [clearPendingApproval]);
useEffect(() => () => {
abortRef.current?.abort();
abortRef.current = null;
abortPendingApproval("Agent unmounted while awaiting approval");
}, [abortPendingApproval]);
const startPreparedReplay = useCallback(() => { const startPreparedReplay = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
+7 -2
View File
@@ -13,6 +13,7 @@ import {
type DemoMode, type DemoMode,
type DemoTimelineState, type DemoTimelineState,
} from "./timeline/reducer.js"; } from "./timeline/reducer.js";
import type { DemoRecording } from "./timeline/models.js";
import { import {
executeLiveDemoStep, executeLiveDemoStep,
failedLiveDemoEvent, failedLiveDemoEvent,
@@ -57,7 +58,7 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
export const useDemoTimeline = ( export const useDemoTimeline = (
target: string | null, target: string | null,
recordEvidence: EvidenceRecorder, recordEvidence: EvidenceRecorder,
recording?: import("./timeline/models.js").DemoRecording, recording?: DemoRecording,
): DemoTimelineController => { ): DemoTimelineController => {
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState); const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext); const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
@@ -67,7 +68,10 @@ export const useDemoTimeline = (
const generationRef = useRef(0); const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false); const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null); const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef(recording ?? loadCanonicalDemoRecording()); const activeRecording = useRef<DemoRecording | null>(recording ?? null);
if (activeRecording.current === null) {
activeRecording.current = loadCanonicalDemoRecording();
}
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null); const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null); const [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -206,6 +210,7 @@ export const useDemoTimeline = (
resetRuntime(); resetRuntime();
if (state.mode === "replay") { if (state.mode === "replay") {
const recording = activeRecording.current; const recording = activeRecording.current;
if (!recording) return;
dispatch({ type: "start", mode: "replay", events: recording.events }); dispatch({ type: "start", mode: "replay", events: recording.events });
} else { } else {
dispatch({ type: "start", mode: "live", events: [] }); dispatch({ type: "start", mode: "live", events: [] });
@@ -16,7 +16,7 @@ type DemoWorkflowSceneProps = {
readonly beat: SceneBeatDefinition; readonly beat: SceneBeatDefinition;
readonly demo: DemoTimelineController; readonly demo: DemoTimelineController;
readonly selectedNodeId: string | null; readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void; readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void; readonly openEvidence: () => void;
}; };
@@ -98,7 +98,7 @@ export const DemoWorkflowScene = ({
</div> </div>
{selectedNodeId && ( {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 { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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"; import { DiscussionPanel } from "./DiscussionPanel.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -8,9 +8,14 @@ afterEach(() => cleanup());
describe("DiscussionPanel", () => { describe("DiscussionPanel", () => {
const onClose = vi.fn(); const onClose = vi.fn();
beforeEach(() => {
onClose.mockClear();
});
it("renders the branch title and claim class", () => { it("renders the branch title and claim class", () => {
render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />); render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />);
expect(screen.getByRole("dialog")).toHaveAttribute("aria-label", "Hosted automation"); 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("Hosted automation")).toBeDefined();
expect(screen.getByText("future-work")).toBeDefined(); expect(screen.getByText("future-work")).toBeDefined();
}); });
@@ -28,6 +33,30 @@ describe("DiscussionPanel", () => {
expect(onClose).toHaveBeenCalledTimes(1); 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", () => { it("shows hosted-automation detail paragraph", () => {
render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />); render(<DiscussionPanel branchId="hosted-automation" onClose={onClose} />);
expect(screen.getByText(/future scheduler/)).toBeDefined(); expect(screen.getByText(/future scheduler/)).toBeDefined();
@@ -1,3 +1,4 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { findDiscussionBranch, findScene } from "./storyboard.js"; import { findDiscussionBranch, findScene } from "./storyboard.js";
type DiscussionPanelProps = { type DiscussionPanelProps = {
@@ -7,33 +8,72 @@ type DiscussionPanelProps = {
export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) => { export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) => {
const branch = findDiscussionBranch(branchId); 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; if (!branch) return null;
const parentScene = findScene(branch.parentSceneId); 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 ( 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> <header>
<h2>{branch.title}</h2> <h2>{branch.title}</h2>
<span className="discussion-panel__badge">{branch.claimClass}</span> <span className="discussion-panel__badge">{branch.claimClass}</span>
</header> </header>
<p className="discussion-panel__evidence">{branch.evidencePointer}</p> <p className="discussion-panel__evidence">{branch.evidencePointer}</p>
<p className="discussion-panel__summary">{branch.summary}</p> <p className="discussion-panel__summary">{branch.summary}</p>
{branchId === "hosted-automation" && ( {branch.detail && (
<p className="discussion-panel__detail"> <p className="discussion-panel__detail">
A future scheduler could trigger a workflow that launches a verified headless {branch.detail.links?.map((link, index) => (
coding-agent command with a stored prompt. lda.chat does not implement that <span key={link.href}>
trigger or scheduler in the submitted scope. {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> </p>
)} )}
{branchId === "mcp-agent-scale" && ( <button ref={returnButtonRef} type="button" onClick={onClose} className="discussion-panel__return">
<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">
Return to {parentScene?.title ?? "scene"} Return to {parentScene?.title ?? "scene"}
</button> </button>
</div> </div>
@@ -1,9 +1,12 @@
import { render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OperatorChat } from "./OperatorChat.js"; import { OperatorChat } from "./OperatorChat.js";
import { initialPresentationState } from "./presentation-state.js"; import { initialPresentationState } from "./presentation-state.js";
import type { AgentMessage } from "../demo/agent/events.js"; import type { AgentMessage } from "../demo/agent/events.js";
afterEach(() => cleanup());
describe("OperatorChat", () => { describe("OperatorChat", () => {
it("renders standard agent message parts", () => { it("renders standard agent message parts", () => {
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
@@ -33,4 +36,77 @@ describe("OperatorChat", () => {
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2); expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2);
expect(screen.getByText(/tool result/i)).toBeInTheDocument(); 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 { PresentationCanvas } from "./PresentationCanvas.js";
import { PresentationStage } from "./PresentationStage.js"; import { PresentationStage } from "./PresentationStage.js";
import { import {
initialPresentationState, createInitialPresentationState,
presentationReducer, presentationReducer,
} from "./presentation-state.js"; } from "./presentation-state.js";
import { hashForLocation } from "./storyboard-navigation.js"; import { hashForLocation } from "./storyboard-navigation.js";
import type { PresentationLocation } from "./storyboard.js"; import type { MainLocation } from "./storyboard.js";
import "./presentation.css"; import "./presentation.css";
import "./styles/demo-workflow.css"; import "./styles/demo-workflow.css";
@@ -33,10 +33,10 @@ const projectRecordingToEvidence = (
export const PresentationRoute = () => { export const PresentationRoute = () => {
const [state, dispatch] = useReducer( const [state, dispatch] = useReducer(
presentationReducer, presentationReducer,
initialPresentationState, window.location.hash,
(initial) => presentationReducer( (initialHash) => presentationReducer(
{ ...initial, startedAt: Date.now() }, createInitialPresentationState(),
{ type: "jump_hash", hash: window.location.hash }, { type: "jump_hash", hash: initialHash },
), ),
); );
@@ -127,10 +127,10 @@ export const PresentationRoute = () => {
if (agent.pendingActions.length > 0) { if (agent.pendingActions.length > 0) {
agent.clearPendingActions(); agent.clearPendingActions();
} }
}, [agent.pendingActions, agent.clearPendingActions, evidence.length, replayEvidence]); }, [agent.pendingActions, agent.clearPendingActions]);
const handleJump = useCallback( 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 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 { findScene, type MainLocation, type PresentationLocation } from "./storyboard.js"; import { findScene, type MainLocation } from "./storyboard.js";
type PresentationStageProps = { type PresentationStageProps = {
readonly state: PresentationState; readonly state: PresentationState;
@@ -18,8 +18,8 @@ type PresentationStageProps = {
readonly messages?: ReadonlyArray<AgentMessage>; readonly messages?: ReadonlyArray<AgentMessage>;
readonly onApprove?: (() => void) | undefined; readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined; readonly onDeny?: (() => void) | undefined;
readonly jump: (location: PresentationLocation) => void; readonly jump: (location: MainLocation) => void;
readonly selectNode: (nodeId: string) => void; readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void; readonly openEvidence: () => void;
readonly closeOverlay: () => void; readonly closeOverlay: () => void;
readonly openDiscussion: (branchId: string) => void; readonly openDiscussion: (branchId: string) => void;
@@ -69,6 +69,7 @@ export const PresentationStage = ({
selectedNodeId={state.selectedNodeId} selectedNodeId={state.selectedNodeId}
selectNode={selectNode} selectNode={selectNode}
openEvidence={openEvidence} openEvidence={openEvidence}
openDiscussion={openDiscussion}
onFocusPathChange={(path) => { onFocusPathChange={(path) => {
if (state.location.kind === "main") { if (state.location.kind === "main") {
jump({ ...state.location, focusPath: path }); jump({ ...state.location, focusPath: path });
@@ -1,5 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react"; 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 { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js"; import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { SceneBody } from "./SceneBody.js"; import { SceneBody } from "./SceneBody.js";
@@ -46,6 +47,7 @@ describe("SceneBody", () => {
selectedNodeId={null} selectedNodeId={null}
selectNode={noop} selectNode={noop}
openEvidence={noop} openEvidence={noop}
openDiscussion={noop}
onFocusPathChange={noop} onFocusPathChange={noop}
motionDisabled={false} motionDisabled={false}
/>, />,
@@ -63,10 +65,33 @@ describe("SceneBody", () => {
selectedNodeId={null} selectedNodeId={null}
selectNode={noop} selectNode={noop}
openEvidence={noop} openEvidence={noop}
openDiscussion={noop}
onFocusPathChange={noop} onFocusPathChange={noop}
motionDisabled={false} motionDisabled={false}
/>, />,
); );
expect(screen.getByLabelText(/workflow graph/i)).toBeInTheDocument(); 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 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 { DemoWorkflowScene } from "./DemoWorkflowScene.js";
import { StageCaption } from "./StageCaption.js"; import { StageCaption } from "./StageCaption.js";
import { ArchitectureScene } from "./scenes/ArchitectureScene.js"; import { ArchitectureScene } from "./scenes/ArchitectureScene.js";
@@ -8,12 +15,37 @@ type SceneBodyProps = {
readonly location: PresentationLocation; readonly location: PresentationLocation;
readonly demo: DemoTimelineController; readonly demo: DemoTimelineController;
readonly selectedNodeId: string | null; readonly selectedNodeId: string | null;
readonly selectNode: (nodeId: string) => void; readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void; readonly openEvidence: () => void;
readonly openDiscussion: (branchId: string) => void;
readonly onFocusPathChange: (path: readonly string[]) => void; readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean; 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 }) => ( const NarrativeScene = ({ scene, beat }: { scene: SceneDefinition; beat: SceneBeatDefinition }) => (
<> <>
<StageCaption eyebrow={`Act ${scene.stageTheme === "paper" ? "I" : "II"} · ${scene.claimClass}`} title={scene.title}> <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}`); 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 sceneId = location.kind === "main" ? location.sceneId : "positioning";
const beatId = location.kind === "main" ? location.beatId : "landscape"; const beatId = location.kind === "main" ? location.beatId : "landscape";
const scene = findScene(sceneId) ?? findScene("thesis")!; const scene = findScene(sceneId) ?? findScene("thesis")!;
const beat = findBeat(sceneId, beatId) ?? scene.beats[0]!; const beat = findBeat(sceneId, beatId) ?? scene.beats[0]!;
const discussionLinks = <DiscussionLinks sceneId={scene.id} openDiscussion={openDiscussion} />;
const content = (() => {
switch (scene.view) { switch (scene.view) {
case "narrative": case "narrative":
return <NarrativeScene scene={scene} beat={beat} />; return <NarrativeScene scene={scene} beat={beat} />;
@@ -224,4 +258,12 @@ export const SceneBody = ({ location, demo, selectedNodeId, selectNode, openEvid
default: default:
return assertNever(scene.view); return assertNever(scene.view);
} }
})();
return (
<>
{content}
{discussionLinks}
</>
);
}; };
@@ -1,4 +1,5 @@
import { m } from "motion/react"; import { m } from "motion/react";
import { useId } from "react";
import type { GraphExecutionPresentation } from "./demo-workflow-model.js"; import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
export type PresentationNode = { export type PresentationNode = {
@@ -54,8 +55,13 @@ export const WorkflowGraphStage = ({
execution, execution,
selectedNodeId, selectedNodeId,
selectNode, selectNode,
}: WorkflowGraphStageProps) => ( }: WorkflowGraphStageProps) => {
<div className="workflow-graph-stage" role="group" aria-label="workflow graph"> 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"> <div className="workflow-graph-stage__legend" aria-hidden="true">
<span><i data-state="completed" />Completed</span> <span><i data-state="completed" />Completed</span>
<span><i data-state="current" />Current</span> <span><i data-state="current" />Current</span>
@@ -64,10 +70,10 @@ export const WorkflowGraphStage = ({
<svg className="workflow-graph-stage__connectors" aria-hidden="true"> <svg className="workflow-graph-stage__connectors" aria-hidden="true">
<defs> <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" /> <polygon points="0 0, 8 3, 0 6" />
</marker> </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" /> <polygon points="0 0, 8 3, 0 6" />
</marker> </marker>
</defs> </defs>
@@ -84,7 +90,7 @@ export const WorkflowGraphStage = ({
y1={`${from.y}%`} y1={`${from.y}%`}
x2={`${to.x}%`} x2={`${to.x}%`}
y2={`${to.y}%`} 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> </m.div>
); );
})} })}
</div> </div>
); );
};
@@ -56,7 +56,7 @@ export const FigureNodeView = ({
<span className="figure-node__kind">{kindLabel[node.kind] ?? node.kind}</span> <span className="figure-node__kind">{kindLabel[node.kind] ?? node.kind}</span>
<strong className="figure-node__label">{node.label}</strong> <strong className="figure-node__label">{node.label}</strong>
<span className="figure-node__summary">{node.summary}</span> <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>} {isActive && <span className="figure-node__current-marker">Current</span>}
</button> </button>
); );
@@ -107,6 +107,14 @@ describe("InteractiveFigure", () => {
expect(container.querySelector(".react-flow__handle-bottom")).toBeInTheDocument(); 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", () => { it("uses left and right handles for flow figures", () => {
const flowCatalog: FigureCatalogDefinition = { const flowCatalog: FigureCatalogDefinition = {
...validCatalog, ...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 { ReactFlow, ReactFlowProvider, Handle, Position, useReactFlow, type Node, type Edge, type NodeTypes } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js"; import type { FigureCatalogDefinition, FigureNodeKind } from "./model.js";
@@ -29,6 +29,7 @@ type FigureNodeData = {
readonly kind: FigureNodeKind; readonly kind: FigureNodeKind;
readonly orientation: "horizontal" | "vertical"; readonly orientation: "horizontal" | "vertical";
readonly isActive: boolean; readonly isActive: boolean;
readonly isFocused: boolean;
readonly isExpandable: boolean; readonly isExpandable: boolean;
readonly onActivate: (nodeId: string) => void; readonly onActivate: (nodeId: string) => void;
readonly onExpand: (nodeId: string) => void; readonly onExpand: (nodeId: string) => void;
@@ -51,7 +52,7 @@ const FigureFlowNode = ({ data }: { data: FigureNodeData }) => {
data-expandable={expandable} data-expandable={expandable}
data-testid={`figure-node-${data.nodeId}`} data-testid={`figure-node-${data.nodeId}`}
aria-label={accessibleName} aria-label={accessibleName}
tabIndex={data.isActive ? 0 : -1} tabIndex={data.isFocused ? 0 : -1}
onClick={() => { onClick={() => {
data.onActivate(data.nodeId); data.onActivate(data.nodeId);
if (expandable) data.onExpand(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> <span className="figure-node__kind">{data.kind}</span>
<strong className="figure-node__label">{data.label}</strong> <strong className="figure-node__label">{data.label}</strong>
<span className="figure-node__summary">{data.summary}</span> <span className="figure-node__summary">{data.summary}</span>
{expandable && <span className="figure-node__expand-affance" aria-hidden="true">&#9656;</span>} {expandable && <span className="figure-node__expand-affordance" aria-hidden="true">&#9656;</span>}
{data.isActive && <span className="figure-node__current-marker">Current</span>} {data.isActive && <span className="figure-node__current-marker">Current</span>}
</button> </button>
<Handle type="source" position={sourcePosition} id="source" /> <Handle type="source" position={sourcePosition} id="source" />
@@ -100,12 +101,18 @@ const InteractiveFigureInner = ({
); );
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]); const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
const containerRef = useRef<HTMLDivElement>(null); 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 ?? ""; const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) { useEffect(() => {
focusedNodeIdRef.current = fallbackFocusedNodeId; 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( const handleExpand = useCallback(
(nodeId: string) => { (nodeId: string) => {
@@ -150,6 +157,7 @@ const InteractiveFigureInner = ({
event.stopPropagation(); event.stopPropagation();
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction); const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
focusedNodeIdRef.current = nextId; focusedNodeIdRef.current = nextId;
setFocusedNodeId(nextId);
const nextNode = containerRef.current?.querySelector( const nextNode = containerRef.current?.querySelector(
`[data-testid="figure-node-${nextId}"]`, `[data-testid="figure-node-${nextId}"]`,
); );
@@ -161,6 +169,7 @@ const InteractiveFigureInner = ({
const handleActivateNode = useCallback((nodeId: string) => { const handleActivateNode = useCallback((nodeId: string) => {
focusedNodeIdRef.current = nodeId; focusedNodeIdRef.current = nodeId;
setFocusedNodeId(nodeId);
}, []); }, []);
const rfNodes: Node[] = useMemo( const rfNodes: Node[] = useMemo(
@@ -176,12 +185,13 @@ const InteractiveFigureInner = ({
kind: node.kind, kind: node.kind,
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical", orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
isActive: node.id === activeNodeId, isActive: node.id === activeNodeId,
isFocused: node.id === focusedNodeId,
isExpandable: node.childFigureId !== undefined, isExpandable: node.childFigureId !== undefined,
onActivate: handleActivateNode, onActivate: handleActivateNode,
onExpand: handleExpand, 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( const rfEdges: Edge[] = useMemo(
@@ -5,6 +5,7 @@ import {
disconnectedCyclicCatalog, disconnectedCyclicCatalog,
duplicateFigureCatalog, duplicateFigureCatalog,
duplicateNodeCatalog, duplicateNodeCatalog,
explicitFigureMissingPosition,
unknownChildCatalog, unknownChildCatalog,
unknownEdgeCatalog, unknownEdgeCatalog,
unknownRootCatalog, unknownRootCatalog,
@@ -27,4 +28,11 @@ describe("defineFigureCatalog", () => {
])("rejects %s", (_label, catalog, code) => { ])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(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_root_figure"; readonly figureId: string }
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: 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: "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 }; | { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
const issueToCode = (issue: FigureCatalogIssue): string => { const issueToCode = (issue: FigureCatalogIssue): string => {
@@ -23,6 +24,8 @@ const issueToCode = (issue: FigureCatalogIssue): string => {
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`; return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
case "unknown_child_figure": case "unknown_child_figure":
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`; return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
case "missing_explicit_position":
return `missing_explicit_position:${issue.figureId}:${issue.nodeId}`;
case "child_cycle": case "child_cycle":
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`; 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 // Detect child-figure cycles from every figure, not just the root, so
// disconnected subgraphs with cycles are also caught. // disconnected subgraphs with cycles are also caught.
const edgeVisited = new Set<string>(); const edgeVisited = new Set<string>();
@@ -116,7 +116,7 @@
max-width: 100%; max-width: 100%;
} }
.figure-node__expand-affance { .figure-node__expand-affordance {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
compositionForState, compositionForState,
createInitialPresentationState,
initialPresentationState, initialPresentationState,
presentationReducer, presentationReducer,
} from "./presentation-state.js"; } from "./presentation-state.js";
@@ -48,6 +49,28 @@ describe("presentationReducer", () => {
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] }); 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", () => { it("opens a discussion branch and returns to the originating beat", () => {
const positioned = presentationReducer(initialPresentationState, { const positioned = presentationReducer(initialPresentationState, {
type: "jump", type: "jump",
@@ -72,6 +95,18 @@ describe("presentationReducer", () => {
expect(state.selectedNodeId).toBe("review_issues"); 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", () => { it("closes overlays in priority order: inspector, node, discussion", () => {
const withNode = presentationReducer(initialPresentationState, { const withNode = presentationReducer(initialPresentationState, {
type: "select_node", type: "select_node",
@@ -29,11 +29,11 @@ export type PresentationState = {
export type PresentationAction = export type PresentationAction =
| { readonly type: "next" } | { readonly type: "next" }
| { readonly type: "previous" } | { readonly type: "previous" }
| { readonly type: "jump"; readonly location: PresentationLocation } | { readonly type: "jump"; readonly location: MainLocation }
| { readonly type: "jump_hash"; readonly hash: string } | { readonly type: "jump_hash"; readonly hash: string }
| { readonly type: "open_discussion"; readonly branchId: string } | { readonly type: "open_discussion"; readonly branchId: string }
| { readonly type: "close_discussion" } | { 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: "clear_node" }
| { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation } | { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation }
| { readonly type: "close_overlay" } | { readonly type: "close_overlay" }
@@ -41,15 +41,17 @@ export type PresentationAction =
| { readonly type: "set_focus_path"; readonly path: readonly string[] } | { readonly type: "set_focus_path"; readonly path: readonly string[] }
| { readonly type: "toggle_motion" }; | { readonly type: "toggle_motion" };
export const initialPresentationState: PresentationState = { export const createInitialPresentationState = (startedAt = Date.now()): PresentationState => ({
location: defaultMainLocation, location: defaultMainLocation,
discussionReturn: null, discussionReturn: null,
selectedNodeId: null, selectedNodeId: null,
evidencePresentationOverride: null, evidencePresentationOverride: null,
playbackMode: "replay", playbackMode: "replay",
motionDisabled: false, motionDisabled: false,
startedAt: Date.now(), startedAt,
}; });
export const initialPresentationState: PresentationState = createInitialPresentationState();
const compositionForLocation = ( const compositionForLocation = (
location: PresentationLocation, location: PresentationLocation,
@@ -130,7 +132,11 @@ export const presentationReducer = (
const returnLoc = branch const returnLoc = branch
? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation ? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation
: defaultMainLocation; : defaultMainLocation;
return { ...moveToLocation(state, parsed), discussionReturn: returnLoc }; return {
...moveToLocation(state, parsed),
discussionReturn: returnLoc,
evidencePresentationOverride: null,
};
} }
case "open_discussion": { case "open_discussion": {
const branch = findDiscussionBranch(action.branchId); const branch = findDiscussionBranch(action.branchId);
@@ -57,6 +57,7 @@
} }
.presentation-stage__primary { .presentation-stage__primary {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@@ -102,6 +103,33 @@
color: oklch(0.72 0.03 250); 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 { .operator-chat {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@@ -1,8 +1,12 @@
import { StageCaption } from "../StageCaption.js"; import { StageCaption } from "../StageCaption.js";
import { InteractiveFigure } from "../figures/InteractiveFigure.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"; import type { SceneDefinition, SceneBeatDefinition } from "../storyboard.js";
const architectureCatalogs = {
[ARCHITECTURE_CATALOG_ID]: architectureCatalog,
} as const;
type ArchitectureSceneProps = { type ArchitectureSceneProps = {
readonly scene: SceneDefinition; readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition; readonly beat: SceneBeatDefinition;
@@ -19,18 +23,26 @@ export const ArchitectureScene = ({
activeNodeId, activeNodeId,
onFocusPathChange, onFocusPathChange,
motionDisabled, motionDisabled,
}: ArchitectureSceneProps) => ( }: ArchitectureSceneProps) => {
<section className="architecture-scene" data-testid="architecture-scene"> // Only one catalog ships today, but resolving through the authored catalog id
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}> // keeps beat metadata honest and makes the next catalog addition localized.
<p>{beat.caption}</p> const catalog = beat.figure
</StageCaption> ? architectureCatalogs[beat.figure.catalogId as keyof typeof architectureCatalogs] ?? architectureCatalog
<InteractiveFigure : architectureCatalog;
catalog={architectureCatalog}
focusPath={focusPath} return (
activeNodeId={activeNodeId} <section className="architecture-scene" data-testid="architecture-scene">
onFocusPathChange={onFocusPathChange} <StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
motionDisabled={motionDisabled} <p>{beat.caption}</p>
size="wide" </StageCaption>
/> <InteractiveFigure
</section> catalog={catalog}
); focusPath={focusPath}
activeNodeId={activeNodeId}
onFocusPathChange={onFocusPathChange}
motionDisabled={motionDisabled}
size="wide"
/>
</section>
);
};
@@ -247,6 +247,13 @@ export type DiscussionBranchDefinition = {
readonly claimClass: ClaimClass; readonly claimClass: ClaimClass;
readonly evidencePointer: string; readonly evidencePointer: string;
readonly summary: 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[]>( const defineDiscussionBranches = <const Branches extends readonly DiscussionBranchDefinition[]>(
@@ -277,6 +284,9 @@ export const discussionBranches = defineDiscussionBranches([
claimClass: "future-work", claimClass: "future-work",
evidencePointer: "Thesis: Workflow Automation Platforms and Future Work", evidencePointer: "Thesis: Workflow Automation Platforms and Future Work",
summary: "Hosted triggers and scheduling are mature elsewhere and remain future work here.", 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", id: "durable-agent-graphs",
@@ -293,6 +303,19 @@ export const discussionBranches = defineDiscussionBranches([
claimClass: "external-context", claimClass: "external-context",
evidencePointer: "Thesis: Model Context Protocol; Anthropic MCP; Cloudflare Code Mode", evidencePointer: "Thesis: Model Context Protocol; Anthropic MCP; Cloudflare Code Mode",
summary: "MCP is a capability protocol; progressive discovery addresses large agent-facing surfaces.", 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", id: "lifecycle-states",
@@ -385,11 +385,11 @@
stroke-dasharray: none; stroke-dasharray: none;
} }
#workflow-arrow polygon { .workflow-graph-stage__arrow-marker polygon {
fill: oklch(0.48 0.035 250); fill: oklch(0.48 0.035 250);
} }
#workflow-arrow-active polygon { .workflow-graph-stage__arrow-marker--active polygon {
fill: var(--accent-cyan); fill: var(--accent-cyan);
} }