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).
Implementation:
[`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.
14. Future: implement Schema Form Surface and synchronized Approval Session.
15. Future: implement Guided Run Beat Gates, presenter companion, Scene 10
15. Future: implement Schema Form Surface and synchronized Approval Session.
16. Future: implement Guided Run Beat Gates, presenter companion, Scene 10
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.
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: (
input: AgentRunInput,
signal: AbortSignal,
requestApproval: () => Promise<AgentApproval>,
requestApproval: (signal: AbortSignal) => Promise<AgentApproval>,
) => AsyncIterable<AgentMessage>;
};
```
@@ -80,8 +80,9 @@ type AgentDriver = {
The first implementation only ships the `prepared-recipe` driver. It produces
events from a hard-coded recipe, calls the existing demo timeline execution
path, and emits presentation actions through the same event stream. The
prepared driver ignores the `requestApproval` callback (deterministic, no
approval needed).
prepared driver uses `requestApproval` at the recipe's typed review step, pauses
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
so a server-side Vercel AI SDK integration can map `streamText` parts into the
@@ -247,7 +248,7 @@ PresentationRoute
-> PreparedRecipeDriver
-> PresentationToolAdapter -> selected node / beat / evidence state
-> AgentMessage[]
-> StandardChat / OperationBlock / EvidenceDrawer / DemoTimeline
-> StandardChat / OperationBlock / EvidenceInspector / DemoTimeline
```
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
existing RPC operation path. Live mode is deferred to a future slice; the
prepared driver is replay-only for now. The `AgentDriver` interface includes
`requestApproval` for future live-mode approval, but the prepared driver
currently ignores it.
`AgentDriver` interface includes `requestApproval` so both the prepared driver
and a future live driver can pause at operator-controlled workflow resume
boundaries.
## Future AI SDK Path
@@ -65,9 +65,9 @@ export async function* runPreparedRecipeReplay(
id: step.id,
role: "assistant",
parts: [
agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: "review_issues" }),
presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: "review_issues" }),
agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: step.toolInput.nodeId }),
presentationActionPart({ type: "selectWorkflowNode", nodeId: step.toolInput.nodeId }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: step.toolInput.nodeId }),
],
};
break;
+15 -7
View File
@@ -1,18 +1,25 @@
import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
import type { PresentationToolName, WorkflowToolName } from "./tools.js";
export type RecipeTool =
| "inspectDeployment"
| "startPreparedReportRun"
| "selectWorkflowNode"
| "resumeIssueReview"
| "readRunTrace";
| Extract<WorkflowToolName, "inspectDeployment" | "startPreparedReportRun" | "resumeIssueReview" | "readRunTrace">
| Extract<PresentationToolName, "selectWorkflowNode">;
export type PreparedRecipeStep = {
type SelectWorkflowNodeStep = {
readonly id: 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 = {
readonly id: "prepare-thesis-report";
readonly title: string;
@@ -46,6 +53,7 @@ export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
id: "focus-interrupt",
narration: "Let's zoom into the typed issue-review interrupt.",
toolName: "selectWorkflowNode",
toolInput: { nodeId: "review_issues" },
},
{
id: "resume",
@@ -123,6 +123,32 @@ describe("useDemoAgent", () => {
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 () => {
const driver = createFakeApprovalDriver(async () => ({ approved: false, comment: "nope" }));
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";
export type DemoAgentPhase = "idle" | "running" | "awaiting-approval" | "completed" | "failed";
@@ -16,62 +16,84 @@ export type DemoAgentController = {
const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> =>
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 => {
const [phase, setPhase] = useState<DemoAgentPhase>("idle");
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([]);
const [pendingActions, setPendingActions] = useState<ReadonlyArray<PresentationToolAction>>([]);
const abortRef = useRef<AbortController | null>(null);
const approvalResolveRef = useRef<((decision: AgentApproval) => void) | null>(null);
const approvalRejectRef = useRef<((error: Error) => void) | null>(null);
const pendingApprovalRef = useRef<PendingApproval | 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(() => {
abortRef.current?.abort();
abortRef.current = null;
if (approvalRejectRef.current) {
approvalRejectRef.current(new DOMException("Agent reset while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
}
abortPendingApproval("Agent reset while awaiting approval");
setPhase("idle");
setMessages([]);
setPendingActions([]);
}, []);
}, [abortPendingApproval]);
const clearPendingActions = useCallback(() => {
setPendingActions([]);
}, []);
const submitApproval = useCallback((decision: AgentApproval) => {
if (approvalResolveRef.current) {
approvalResolveRef.current(decision);
approvalResolveRef.current = null;
approvalRejectRef.current = null;
setPhase("running");
}
}, []);
const pending = pendingApprovalRef.current;
if (!pending) return;
clearPendingApproval(pending);
pending.resolve(decision);
setPhase("running");
}, [clearPendingApproval]);
const requestApproval = useCallback((signal: AbortSignal): Promise<AgentApproval> => {
setPhase("awaiting-approval");
return new Promise<AgentApproval>((resolve, reject) => {
approvalResolveRef.current = resolve;
approvalRejectRef.current = reject;
if (signal.aborted) {
reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
return;
}
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"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
};
const pending: PendingApproval = {
resolve,
reject,
signal,
abortHandler: onAbort,
};
pendingApprovalRef.current = pending;
signal.addEventListener("abort", onAbort);
});
}, []);
}, [clearPendingApproval]);
useEffect(() => () => {
abortRef.current?.abort();
abortRef.current = null;
abortPendingApproval("Agent unmounted while awaiting approval");
}, [abortPendingApproval]);
const startPreparedReplay = useCallback(() => {
abortRef.current?.abort();
+7 -2
View File
@@ -13,6 +13,7 @@ import {
type DemoMode,
type DemoTimelineState,
} from "./timeline/reducer.js";
import type { DemoRecording } from "./timeline/models.js";
import {
executeLiveDemoStep,
failedLiveDemoEvent,
@@ -57,7 +58,7 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
export const useDemoTimeline = (
target: string | null,
recordEvidence: EvidenceRecorder,
recording?: import("./timeline/models.js").DemoRecording,
recording?: DemoRecording,
): DemoTimelineController => {
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
@@ -67,7 +68,10 @@ export const useDemoTimeline = (
const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false);
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 [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -206,6 +210,7 @@ export const useDemoTimeline = (
resetRuntime();
if (state.mode === "replay") {
const recording = activeRecording.current;
if (!recording) return;
dispatch({ type: "start", mode: "replay", events: recording.events });
} else {
dispatch({ type: "start", mode: "live", events: [] });
@@ -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">&#9656;</span>}
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">&#9656;</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);
}