fix: address presentation review findings
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">▸</span>}
|
||||
{expandable && <span className="figure-node__expand-affordance" aria-hidden="true">▸</span>}
|
||||
{data.isActive && <span className="figure-node__current-marker">Current</span>}
|
||||
</button>
|
||||
<Handle type="source" position={sourcePosition} id="source" />
|
||||
@@ -100,12 +101,18 @@ const InteractiveFigureInner = ({
|
||||
);
|
||||
const layout = useMemo(() => layoutFigure(focus.figure), [focus.figure]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const focusedNodeIdRef = useRef(activeNodeId ?? focus.figure.nodes[0]?.id ?? "");
|
||||
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
|
||||
const focusedNodeIdRef = useRef(initialFocusedNodeId);
|
||||
|
||||
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
|
||||
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
|
||||
focusedNodeIdRef.current = fallbackFocusedNodeId;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!fallbackFocusedNodeId) return;
|
||||
if (activeNodeId || !layout.nodes.some((node) => node.id === focusedNodeIdRef.current)) {
|
||||
focusedNodeIdRef.current = fallbackFocusedNodeId;
|
||||
setFocusedNodeId(fallbackFocusedNodeId);
|
||||
}
|
||||
}, [activeNodeId, fallbackFocusedNodeId, layout.nodes]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
(nodeId: string) => {
|
||||
@@ -150,6 +157,7 @@ const InteractiveFigureInner = ({
|
||||
event.stopPropagation();
|
||||
const nextId = nextFigureNodeId(layout, focusedNodeIdRef.current, direction);
|
||||
focusedNodeIdRef.current = nextId;
|
||||
setFocusedNodeId(nextId);
|
||||
const nextNode = containerRef.current?.querySelector(
|
||||
`[data-testid="figure-node-${nextId}"]`,
|
||||
);
|
||||
@@ -161,6 +169,7 @@ const InteractiveFigureInner = ({
|
||||
|
||||
const handleActivateNode = useCallback((nodeId: string) => {
|
||||
focusedNodeIdRef.current = nodeId;
|
||||
setFocusedNodeId(nodeId);
|
||||
}, []);
|
||||
|
||||
const rfNodes: Node[] = useMemo(
|
||||
@@ -176,12 +185,13 @@ const InteractiveFigureInner = ({
|
||||
kind: node.kind,
|
||||
orientation: layout.definition.layout.kind === "flow" ? "horizontal" : "vertical",
|
||||
isActive: node.id === activeNodeId,
|
||||
isFocused: node.id === focusedNodeId,
|
||||
isExpandable: node.childFigureId !== undefined,
|
||||
onActivate: handleActivateNode,
|
||||
onExpand: handleExpand,
|
||||
},
|
||||
})),
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, handleActivateNode, handleExpand],
|
||||
[layout.definition.layout.kind, layout.nodes, activeNodeId, focusedNodeId, handleActivateNode, handleExpand],
|
||||
);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
disconnectedCyclicCatalog,
|
||||
duplicateFigureCatalog,
|
||||
duplicateNodeCatalog,
|
||||
explicitFigureMissingPosition,
|
||||
unknownChildCatalog,
|
||||
unknownEdgeCatalog,
|
||||
unknownRootCatalog,
|
||||
@@ -27,4 +28,11 @@ describe("defineFigureCatalog", () => {
|
||||
])("rejects %s", (_label, catalog, code) => {
|
||||
expect(() => defineFigureCatalog(catalog)).toThrow(code);
|
||||
});
|
||||
|
||||
it("rejects explicit layouts missing a node position", () => {
|
||||
expect(() => defineFigureCatalog({
|
||||
rootFigureId: explicitFigureMissingPosition.id,
|
||||
figures: [explicitFigureMissingPosition],
|
||||
})).toThrow("missing_explicit_position:explicit-missing:runtime");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FigureCatalogIssue =
|
||||
| { readonly code: "unknown_root_figure"; readonly figureId: string }
|
||||
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
|
||||
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
|
||||
| { readonly code: "missing_explicit_position"; readonly figureId: string; readonly nodeId: string }
|
||||
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
|
||||
|
||||
const issueToCode = (issue: FigureCatalogIssue): string => {
|
||||
@@ -23,6 +24,8 @@ const issueToCode = (issue: FigureCatalogIssue): string => {
|
||||
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
|
||||
case "unknown_child_figure":
|
||||
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
|
||||
case "missing_explicit_position":
|
||||
return `missing_explicit_position:${issue.figureId}:${issue.nodeId}`;
|
||||
case "child_cycle":
|
||||
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
|
||||
}
|
||||
@@ -87,6 +90,15 @@ export const defineFigureCatalog = (
|
||||
}
|
||||
}
|
||||
|
||||
for (const figure of catalog.figures) {
|
||||
if (figure.layout.kind !== "explicit") continue;
|
||||
for (const node of figure.nodes) {
|
||||
if (figure.layout.positions[node.id] === undefined) {
|
||||
issues.push({ code: "missing_explicit_position", figureId: figure.id, nodeId: node.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect child-figure cycles from every figure, not just the root, so
|
||||
// disconnected subgraphs with cycles are also caught.
|
||||
const edgeVisited = new Set<string>();
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.figure-node__expand-affance {
|
||||
.figure-node__expand-affordance {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compositionForState,
|
||||
createInitialPresentationState,
|
||||
initialPresentationState,
|
||||
presentationReducer,
|
||||
} from "./presentation-state.js";
|
||||
@@ -48,6 +49,28 @@ describe("presentationReducer", () => {
|
||||
expect(state.location).toEqual({ kind: "main", sceneId: "thesis", beatId: "title", focusPath: [] });
|
||||
});
|
||||
|
||||
it("clears evidence overrides when deep-linking into a discussion branch", () => {
|
||||
const state = presentationReducer(
|
||||
{
|
||||
...initialPresentationState,
|
||||
evidencePresentationOverride: "inspector",
|
||||
},
|
||||
{
|
||||
type: "jump_hash",
|
||||
hash: "#discuss/hosted-automation",
|
||||
},
|
||||
);
|
||||
|
||||
expect(state.location).toEqual({ kind: "discussion", branchId: "hosted-automation" });
|
||||
expect(state.evidencePresentationOverride).toBeNull();
|
||||
expect(state.discussionReturn).toEqual({ kind: "main", sceneId: "positioning", beatId: "landscape", focusPath: [] });
|
||||
});
|
||||
|
||||
it("creates fresh startedAt values per reducer session", () => {
|
||||
expect(createInitialPresentationState(100).startedAt).toBe(100);
|
||||
expect(createInitialPresentationState(250).startedAt).toBe(250);
|
||||
});
|
||||
|
||||
it("opens a discussion branch and returns to the originating beat", () => {
|
||||
const positioned = presentationReducer(initialPresentationState, {
|
||||
type: "jump",
|
||||
@@ -72,6 +95,18 @@ describe("presentationReducer", () => {
|
||||
expect(state.selectedNodeId).toBe("review_issues");
|
||||
});
|
||||
|
||||
it("clears node detail through nullable selection", () => {
|
||||
const withNode = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
nodeId: "review_issues",
|
||||
});
|
||||
const cleared = presentationReducer(withNode, {
|
||||
type: "select_node",
|
||||
nodeId: null,
|
||||
});
|
||||
expect(cleared.selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("closes overlays in priority order: inspector, node, discussion", () => {
|
||||
const withNode = presentationReducer(initialPresentationState, {
|
||||
type: "select_node",
|
||||
|
||||
@@ -29,11 +29,11 @@ export type PresentationState = {
|
||||
export type PresentationAction =
|
||||
| { readonly type: "next" }
|
||||
| { readonly type: "previous" }
|
||||
| { readonly type: "jump"; readonly location: PresentationLocation }
|
||||
| { readonly type: "jump"; readonly location: MainLocation }
|
||||
| { readonly type: "jump_hash"; readonly hash: string }
|
||||
| { readonly type: "open_discussion"; readonly branchId: string }
|
||||
| { readonly type: "close_discussion" }
|
||||
| { readonly type: "select_node"; readonly nodeId: string }
|
||||
| { readonly type: "select_node"; readonly nodeId: string | null }
|
||||
| { readonly type: "clear_node" }
|
||||
| { readonly type: "set_evidence_presentation"; readonly presentation: EvidencePresentation }
|
||||
| { readonly type: "close_overlay" }
|
||||
@@ -41,15 +41,17 @@ export type PresentationAction =
|
||||
| { readonly type: "set_focus_path"; readonly path: readonly string[] }
|
||||
| { readonly type: "toggle_motion" };
|
||||
|
||||
export const initialPresentationState: PresentationState = {
|
||||
export const createInitialPresentationState = (startedAt = Date.now()): PresentationState => ({
|
||||
location: defaultMainLocation,
|
||||
discussionReturn: null,
|
||||
selectedNodeId: null,
|
||||
evidencePresentationOverride: null,
|
||||
playbackMode: "replay",
|
||||
motionDisabled: false,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
startedAt,
|
||||
});
|
||||
|
||||
export const initialPresentationState: PresentationState = createInitialPresentationState();
|
||||
|
||||
const compositionForLocation = (
|
||||
location: PresentationLocation,
|
||||
@@ -130,7 +132,11 @@ export const presentationReducer = (
|
||||
const returnLoc = branch
|
||||
? firstBeatOfScene(branch.parentSceneId) ?? defaultMainLocation
|
||||
: defaultMainLocation;
|
||||
return { ...moveToLocation(state, parsed), discussionReturn: returnLoc };
|
||||
return {
|
||||
...moveToLocation(state, parsed),
|
||||
discussionReturn: returnLoc,
|
||||
evidencePresentationOverride: null,
|
||||
};
|
||||
}
|
||||
case "open_discussion": {
|
||||
const branch = findDiscussionBranch(action.branchId);
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
|
||||
.presentation-stage__primary {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
@@ -102,6 +103,33 @@
|
||||
color: oklch(0.72 0.03 250);
|
||||
}
|
||||
|
||||
.scene-body__discussion-links {
|
||||
position: absolute;
|
||||
left: 2rem;
|
||||
bottom: 1.4rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
max-width: min(52rem, calc(100% - 4rem));
|
||||
}
|
||||
|
||||
.scene-body__discussion-links button {
|
||||
border: 1px solid oklch(0.82 0.04 82 / 0.22);
|
||||
border-radius: 999px;
|
||||
background: oklch(0.16 0.018 65 / 0.74);
|
||||
color: oklch(0.9 0.025 82);
|
||||
padding: 0.28rem 0.7rem;
|
||||
font: 600 0.72rem/1 var(--font-interface);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scene-body__discussion-links button:hover,
|
||||
.scene-body__discussion-links button:focus-visible {
|
||||
border-color: var(--accent-cyan);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.operator-chat {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { StageCaption } from "../StageCaption.js";
|
||||
import { InteractiveFigure } from "../figures/InteractiveFigure.js";
|
||||
import { architectureCatalog } from "../figures/architecture-catalog.js";
|
||||
import { ARCHITECTURE_CATALOG_ID, architectureCatalog } from "../figures/architecture-catalog.js";
|
||||
import type { SceneDefinition, SceneBeatDefinition } from "../storyboard.js";
|
||||
|
||||
const architectureCatalogs = {
|
||||
[ARCHITECTURE_CATALOG_ID]: architectureCatalog,
|
||||
} as const;
|
||||
|
||||
type ArchitectureSceneProps = {
|
||||
readonly scene: SceneDefinition;
|
||||
readonly beat: SceneBeatDefinition;
|
||||
@@ -19,18 +23,26 @@ export const ArchitectureScene = ({
|
||||
activeNodeId,
|
||||
onFocusPathChange,
|
||||
motionDisabled,
|
||||
}: ArchitectureSceneProps) => (
|
||||
<section className="architecture-scene" data-testid="architecture-scene">
|
||||
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<InteractiveFigure
|
||||
catalog={architectureCatalog}
|
||||
focusPath={focusPath}
|
||||
activeNodeId={activeNodeId}
|
||||
onFocusPathChange={onFocusPathChange}
|
||||
motionDisabled={motionDisabled}
|
||||
size="wide"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}: ArchitectureSceneProps) => {
|
||||
// Only one catalog ships today, but resolving through the authored catalog id
|
||||
// keeps beat metadata honest and makes the next catalog addition localized.
|
||||
const catalog = beat.figure
|
||||
? architectureCatalogs[beat.figure.catalogId as keyof typeof architectureCatalogs] ?? architectureCatalog
|
||||
: architectureCatalog;
|
||||
|
||||
return (
|
||||
<section className="architecture-scene" data-testid="architecture-scene">
|
||||
<StageCaption eyebrow={`Act II · ${scene.claimClass}`} title={scene.title}>
|
||||
<p>{beat.caption}</p>
|
||||
</StageCaption>
|
||||
<InteractiveFigure
|
||||
catalog={catalog}
|
||||
focusPath={focusPath}
|
||||
activeNodeId={activeNodeId}
|
||||
onFocusPathChange={onFocusPathChange}
|
||||
motionDisabled={motionDisabled}
|
||||
size="wide"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,6 +247,13 @@ export type DiscussionBranchDefinition = {
|
||||
readonly claimClass: ClaimClass;
|
||||
readonly evidencePointer: string;
|
||||
readonly summary: string;
|
||||
readonly detail?: {
|
||||
readonly text: string;
|
||||
readonly links?: ReadonlyArray<{
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const defineDiscussionBranches = <const Branches extends readonly DiscussionBranchDefinition[]>(
|
||||
@@ -277,6 +284,9 @@ export const discussionBranches = defineDiscussionBranches([
|
||||
claimClass: "future-work",
|
||||
evidencePointer: "Thesis: Workflow Automation Platforms and Future Work",
|
||||
summary: "Hosted triggers and scheduling are mature elsewhere and remain future work here.",
|
||||
detail: {
|
||||
text: "A future scheduler could trigger a workflow that launches a verified headless coding-agent command with a stored prompt. lda.chat does not implement that trigger or scheduler in the submitted scope.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "durable-agent-graphs",
|
||||
@@ -293,6 +303,19 @@ export const discussionBranches = defineDiscussionBranches([
|
||||
claimClass: "external-context",
|
||||
evidencePointer: "Thesis: Model Context Protocol; Anthropic MCP; Cloudflare Code Mode",
|
||||
summary: "MCP is a capability protocol; progressive discovery addresses large agent-facing surfaces.",
|
||||
detail: {
|
||||
text: "Both are external context.",
|
||||
links: [
|
||||
{
|
||||
label: "Anthropic MCP",
|
||||
href: "https://www.anthropic.com/engineering/code-execution-with-mcp",
|
||||
},
|
||||
{
|
||||
label: "Cloudflare Code Mode",
|
||||
href: "https://blog.cloudflare.com/code-mode-mcp/",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lifecycle-states",
|
||||
|
||||
@@ -385,11 +385,11 @@
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
#workflow-arrow polygon {
|
||||
.workflow-graph-stage__arrow-marker polygon {
|
||||
fill: oklch(0.48 0.035 250);
|
||||
}
|
||||
|
||||
#workflow-arrow-active polygon {
|
||||
.workflow-graph-stage__arrow-marker--active polygon {
|
||||
fill: var(--accent-cyan);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user