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