fix: address console review findings

This commit is contained in:
lda
2026-07-03 08:27:36 +07:00 Verified
parent 47ac422965
commit b0b2cba7ab
23 changed files with 351 additions and 107 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ export const ConsoleHome = () => {
loading={state.sourcesLoading}
error={state.sourceError}
/>
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer">
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer" data-panel="lifecycle-explorer">
<LifecycleExplorer controller={lifecycleController} />
</section>
</div>
@@ -8,7 +8,7 @@ type Props = {
export const SourceInventory = ({ sources, loading, error }: Props) => {
return (
<section aria-label="Source Inventory">
<section aria-label="Source Inventory" data-panel="source-inventory">
<h2>Sources</h2>
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
{error && (
@@ -1,16 +1,24 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
import { DemoTimelineControls } from "./DemoTimelineControls.js";
import { DemoTimeline } from "./DemoTimeline.js";
import type { DemoTimelineController } from "./useDemoTimeline.js";
const DEFAULT_REVIEW_COMMENT = "Create selected issues before the defense.";
export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
const { state } = controller;
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [comment, setComment] = useState("Create selected issues before the defense.");
const [comment, setComment] = useState(DEFAULT_REVIEW_COMMENT);
const proposedIssues = controller.interruptPayload?.proposed_issues ?? [];
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
useEffect(() => {
setSelectedIds(new Set());
setComment(DEFAULT_REVIEW_COMMENT);
}, [state.mode, state.phase, controller.interruptPayload]);
return (
<section aria-label="lda report workflow demo" className="demo-panel">
<div className="demo-panel__header">
+35 -21
View File
@@ -51,6 +51,25 @@ export type LiveStepResult = {
readonly events: ReadonlyArray<DemoEvent>;
};
const baseDemoEvent = (
context: LiveDemoContext,
stage: DemoEventStage,
runId: string | null,
sequenceOffset = 0,
): Pick<DemoEvent, "id" | "sequence" | "stage" | "resultingIds" | "recordedAt"> => {
const sequence = context.nextSequence + sequenceOffset;
return {
id: `live-${sequence}-${stage}-${runId ?? "pending"}`,
sequence,
stage,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId,
},
recordedAt: new Date().toISOString(),
};
};
const eventFromResult = (
context: LiveDemoContext,
stage: DemoEventStage,
@@ -60,9 +79,7 @@ const eventFromResult = (
result: RpcResponse,
runId: string | null,
): DemoEvent => ({
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`,
sequence: context.nextSequence,
stage,
...baseDemoEvent(context, stage, runId),
operation,
reason: result.ok ? reason : result.error.message,
equivalentCli: result.ok ? result.equivalentCli : null,
@@ -70,11 +87,6 @@ const eventFromResult = (
rawResponse: result.exchange.response,
interpreted: result.ok ? result.interpreted : null,
durationMs: result.ok ? result.durationMs : 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId,
},
recordedAt: new Date().toISOString(),
});
const syntheticEvent = (
@@ -85,9 +97,7 @@ const syntheticEvent = (
runId: string | null,
sequenceOffset = 0,
): DemoEvent => ({
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`,
sequence: context.nextSequence + sequenceOffset,
stage,
...baseDemoEvent(context, stage, runId, sequenceOffset),
operation: null,
reason,
equivalentCli: null,
@@ -95,8 +105,6 @@ const syntheticEvent = (
rawResponse: null,
interpreted,
durationMs: 0,
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
recordedAt: new Date().toISOString(),
});
const operationForStage = (stage: LiveDemoStage): string | null => {
@@ -118,9 +126,7 @@ export const failedLiveDemoEvent = (
context: LiveDemoContext,
reason: string,
): DemoEvent => ({
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`,
sequence: context.nextSequence,
stage: "failed",
...baseDemoEvent(context, "failed", context.runId),
operation: operationForStage(context.nextStage),
reason,
equivalentCli: null,
@@ -128,11 +134,6 @@ export const failedLiveDemoEvent = (
rawResponse: null,
interpreted: null,
durationMs: 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId: context.runId,
},
recordedAt: new Date().toISOString(),
});
export const executeLiveDemoStep = async (
@@ -238,6 +239,19 @@ export const executeLiveDemoStep = async (
};
}
const detail = decodeRunDetail(result.interpreted);
if (detail.status !== "completed") {
const failed = syntheticEvent(
context,
"failed",
`Demo resume returned ${detail.status} instead of completed.`,
result.interpreted,
detail.runId,
);
return {
events: [failed],
context: { ...context, nextStage: "done", runId: detail.runId, nextSequence: context.nextSequence + 1 },
};
}
const output = parseLdaReportOutput(detail.output);
return {
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
+28 -17
View File
@@ -63,6 +63,7 @@ export const useDemoTimeline = (
const recordEvidenceRef = useRef(recordEvidence);
recordEvidenceRef.current = recordEvidence;
const inFlightRef = useRef(false);
const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef(loadCanonicalDemoRecording());
@@ -71,9 +72,25 @@ export const useDemoTimeline = (
const [output, setOutput] = useState<LdaReportOutput | null>(null);
const [trace, setTrace] = useState<TracePage | null>(null);
const resetRuntime = useCallback(() => {
generationRef.current++;
inFlightRef.current = false;
setInFlight(false);
liveContextRef.current = initialLiveDemoContext;
approvalRef.current = null;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
useEffect(() => {
resetRuntime();
}, [target, resetRuntime]);
const step = useCallback(async () => {
if (inFlightRef.current) return;
if (state.appliedCount >= state.events.length && state.mode === "replay") return;
const generation = generationRef.current;
inFlightRef.current = true;
setInFlight(true);
try {
@@ -102,6 +119,7 @@ export const useDemoTimeline = (
const approval = approvalRef.current;
approvalRef.current = null;
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
if (generation !== generationRef.current) return;
liveContextRef.current = result.context;
for (const event of result.events) {
dispatch({ type: "append_live_event", event });
@@ -162,8 +180,10 @@ export const useDemoTimeline = (
dispatch({ type: "fail", message });
}
} finally {
inFlightRef.current = false;
setInFlight(false);
if (generation === generationRef.current) {
inFlightRef.current = false;
setInFlight(false);
}
}
}, [state.mode, state.appliedCount, state.events, target]);
@@ -177,25 +197,19 @@ export const useDemoTimeline = (
}, [state.phase, state.autoplay, state.appliedCount, step]);
const setMode = useCallback((mode: DemoMode) => {
resetRuntime();
dispatch({ type: "set_mode", mode });
liveContextRef.current = initialLiveDemoContext;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
}, [resetRuntime]);
const start = useCallback(() => {
resetRuntime();
if (state.mode === "replay") {
const recording = activeRecording.current;
dispatch({ type: "start", mode: "replay", events: recording.events });
} else {
liveContextRef.current = initialLiveDemoContext;
dispatch({ type: "start", mode: "live", events: [] });
}
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, [state.mode]);
}, [resetRuntime, state.mode]);
const pause = useCallback(() => dispatch({ type: "pause" }), []);
const play = useCallback(() => dispatch({ type: "play" }), []);
@@ -229,12 +243,9 @@ export const useDemoTimeline = (
}, []);
const restart = useCallback(() => {
resetRuntime();
dispatch({ type: "restart" });
liveContextRef.current = initialLiveDemoContext;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
}, [resetRuntime]);
return {
state,
@@ -96,6 +96,18 @@ describe("WorkflowGraph", () => {
expect(onSelect).toHaveBeenCalledWith("review");
});
it("calls onNodeSelect when a focused node is activated by keyboard", () => {
const onSelect = vi.fn();
const { container } = render(<WorkflowGraph model={mockModel} onNodeSelect={onSelect} />);
const reviewNode = findNodeById(container, "review");
fireEvent.keyDown(reviewNode!, { key: "Enter" });
fireEvent.keyDown(reviewNode!, { key: " " });
expect(onSelect).toHaveBeenNthCalledWith(1, "review");
expect(onSelect).toHaveBeenNthCalledWith(2, "review");
});
it("highlights active node when activeNodeId is provided", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
const reviewNode = findNodeById(container, "review");
+9 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, type KeyboardEvent } from "react";
import {
ReactFlow,
Background,
@@ -39,10 +39,16 @@ const nodeColor = (data: WorkflowGraphNodeData): string => {
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
data.onSelect?.(data.nodeId);
};
return (
<div
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
data-active={isActive}
data-node-id={data.nodeId}
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
@@ -69,9 +75,9 @@ export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: Work
id: n.id,
type: "custom",
position: n.position,
data: { ...n.data, isActive: activeNodeId === n.id },
data: { ...n.data, isActive: activeNodeId === n.id, onSelect: onNodeSelect },
})),
[model.nodes, activeNodeId],
[model.nodes, activeNodeId, onNodeSelect],
);
const edges: Edge[] = useMemo(
@@ -64,6 +64,24 @@ describe("buildWorkflowGraph", () => {
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
});
it("labels subgraph nodes from the workflow name", () => {
const model = buildWorkflowGraph({
nodes: [
{
id: "nested",
type: "subgraph",
workflow: "workflows.report.review",
input: [],
output: [],
},
],
edges: [],
});
expect(model.nodes[0]?.data.kind).toBe("subgraph");
expect(model.nodes[0]?.data.label).toBe("review");
});
it("creates edges from plan edges", () => {
const model = buildWorkflowGraph(samplePlan);
expect(model.edges.length).toBe(5);
@@ -15,6 +15,7 @@ export type WorkflowGraphNodeData = {
readonly label: string;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
readonly onSelect?: (nodeId: string) => void;
};
export type WorkflowGraphNode = {
@@ -66,6 +67,14 @@ const buildLabel = (node: Record<string, unknown>): string => {
if (type === "interrupt") return (node.kind as string) ?? "Interrupt";
if (type === "foreach") return "For Each";
if (type === "join") return "Join";
if (type === "subgraph") {
const workflowRef = node.workflow as string | undefined;
if (workflowRef) {
const parts = workflowRef.split(".");
return parts[parts.length - 1] ?? workflowRef;
}
return "Subgraph";
}
const nodeRef = node.node as string | undefined;
if (nodeRef) {
const parts = nodeRef.split(".");
@@ -150,4 +150,55 @@ describe("LifecycleExplorer", () => {
render(<LifecycleExplorer controller={controller} />);
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
});
it("keeps previous records visible while showing loading and error status", () => {
const controller = createMockController({
artifactList: {
phase: "loading",
previous: {
items: [
{
key: "report@1",
artifactId: "report",
version: 1,
kind: "workflow",
displayName: "Report",
description: null,
outcomes: ["ok"],
requiredSources: ["local.report"],
diagnosticCount: 0,
},
],
total: 1,
nextCursor: null,
},
},
runList: {
phase: "error",
message: "network down",
previous: {
items: [
{
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_needed",
diagnosticCount: 0,
},
],
total: 1,
nextCursor: null,
},
},
});
render(<LifecycleExplorer controller={controller} />);
expect(screen.getByText(/loading artifacts/i)).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent(/could not load runs: network down/i);
expect(screen.getByRole("option", { name: /Report version 1/i })).toBeVisible();
expect(screen.getByRole("option", { name: /run_1 completed/i })).toBeVisible();
});
});
@@ -6,6 +6,7 @@ import { buildWorkflowGraph } from "../graph/graph-model.js";
import { WorkflowGraph } from "../graph/WorkflowGraph.js";
import { buildTraceFrames } from "../execution/trace-model.js";
import { ExecutionView } from "../execution/ExecutionView.js";
import type { LoadState } from "./state.js";
type LifecycleExplorerProps = {
readonly controller: LifecycleExplorerController;
@@ -13,18 +14,24 @@ type LifecycleExplorerProps = {
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
const loadedItems = <T,>(
state: LoadState<{ readonly items: ReadonlyArray<T> }>,
): ReadonlyArray<T> =>
state.phase === "loaded" ? state.value.items : state.phase === "loading" || state.phase === "error" ? state.previous?.items ?? [] : [];
const listStatus = <T,>(label: string, state: LoadState<T>) => {
if (state.phase === "loading") return <p role="status">Loading {label}...</p>;
if (state.phase === "error") return <p role="alert">Could not load {label}: {state.message}</p>;
return null;
};
export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
const { state } = controller;
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
const artifacts =
state.artifactList.phase === "loaded" ? state.artifactList.value.items : [];
const deployments =
state.deploymentList.phase === "loaded"
? state.deploymentList.value.items
: [];
const runs =
state.runList.phase === "loaded" ? state.runList.value.items : [];
const artifacts = loadedItems(state.artifactList);
const deployments = loadedItems(state.deploymentList);
const runs = loadedItems(state.runList);
const graphModel = useMemo(() => {
if (!state.artifactDetail?.plan) return null;
@@ -74,6 +81,16 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
{focusMode === "lifecycle" && (
<div className="lifecycle-content">
<div className="lifecycle-status">
{listStatus("artifacts", state.artifactList)}
{listStatus("deployments", state.deploymentList)}
{listStatus("runs", state.runList)}
{state.errors.map((error) => (
<p role="alert" key={`${error.operation}-${error.timestamp}`}>
{error.operation}: {error.message}
</p>
))}
</div>
<RecordColumns
artifacts={artifacts}
deployments={deployments}
@@ -1,9 +1,9 @@
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
type RecordColumnsProps = {
readonly artifacts: ArtifactSummary[];
readonly deployments: DeploymentSummary[];
readonly runs: RunSummary[];
readonly artifacts: ReadonlyArray<ArtifactSummary>;
readonly deployments: ReadonlyArray<DeploymentSummary>;
readonly runs: ReadonlyArray<RunSummary>;
readonly selectedArtifactId: string | null;
readonly selectedDeploymentId: string | null;
readonly selectedRunId: string | null;
@@ -1,4 +1,4 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import { useReducer, useEffect, useRef, useCallback, type MutableRefObject } from "react";
import { callOperation } from "../connection/api.js";
import type { OperationName } from "../connection/contracts.js";
import {
@@ -26,15 +26,7 @@ export type LifecycleExplorerController = {
export const useLifecycleExplorer = (
target: string | null,
recordEvidence: (record: {
id: string;
operation: string;
label: string;
equivalentCli: string;
request: unknown;
response: unknown;
durationMs: number;
}) => void,
recordEvidence: (record: EvidenceRecord) => void,
): LifecycleExplorerController => {
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
const generationRef = useRef(0);
@@ -49,8 +41,9 @@ export const useLifecycleExplorer = (
operation: OperationName,
params: unknown,
generation: number,
checkGenerationRef: React.MutableRefObject<number>,
checkGenerationRef: MutableRefObject<number>,
onSuccess: (interpreted: unknown) => void,
onFailure?: (message: string) => void,
) => {
if (!target) return;
try {
@@ -83,6 +76,7 @@ export const useLifecycleExplorer = (
});
}
} else {
onFailure?.(result.error.message);
const seq = evidenceSeqRef.current++;
const record: EvidenceRecord = {
id: `${operation}-${seq}`,
@@ -106,11 +100,14 @@ export const useLifecycleExplorer = (
});
}
} catch (rpcError) {
if (generation !== checkGenerationRef.current) return;
const message = rpcError instanceof Error ? rpcError.message : String(rpcError);
onFailure?.(message);
dispatch({
type: "pushError",
error: {
operation,
message: rpcError instanceof Error ? rpcError.message : String(rpcError),
message,
timestamp: Date.now(),
},
});
@@ -122,20 +119,32 @@ export const useLifecycleExplorer = (
useEffect(() => {
if (!target) return;
generationRef.current++;
artifactGenerationRef.current++;
deploymentGenerationRef.current++;
runGenerationRef.current++;
const generation = generationRef.current;
rawEvidenceRef.current = [];
dispatch({ type: "targetChanged" });
dispatch({ type: "setArtifactListPhase", phase: "loading" });
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
dispatch({ type: "setRunListPhase", phase: "loading" });
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
}, (message) => {
dispatch({ type: "setArtifactListPhase", phase: "error", message });
});
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
}, (message) => {
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
});
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
});
}, [target, executeOperation]);
@@ -223,28 +232,42 @@ export const useLifecycleExplorer = (
const refresh = useCallback(() => {
if (!target) return;
generationRef.current++;
const generation = generationRef.current;
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
artifactGenerationRef.current++;
deploymentGenerationRef.current++;
runGenerationRef.current++;
const artifactGeneration = artifactGenerationRef.current;
const deploymentGeneration = deploymentGenerationRef.current;
const runGeneration = runGenerationRef.current;
dispatch({ type: "setArtifactListPhase", phase: "loading" });
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
dispatch({ type: "setRunListPhase", phase: "loading" });
executeOperation("workflow.artifacts.list", { limit: 50 }, artifactGeneration, artifactGenerationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
}, (message) => {
dispatch({ type: "setArtifactListPhase", phase: "error", message });
});
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
executeOperation("workflow.deployments.list", {}, deploymentGeneration, deploymentGenerationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
}, (message) => {
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
});
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
executeOperation("workflow.runs.list", { limit: 50 }, runGeneration, runGenerationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
});
}, [target, executeOperation]);
const loadMoreArtifacts = useCallback(() => {
const current = state.artifactList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++;
const generation = generationRef.current;
artifactGenerationRef.current++;
const generation = artifactGenerationRef.current;
executeOperation(
"workflow.artifacts.list",
{ cursor: current.value.nextCursor, limit: 50 },
generation,
generationRef,
artifactGenerationRef,
(interpreted) => {
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
},
@@ -254,13 +277,13 @@ export const useLifecycleExplorer = (
const loadMoreRuns = useCallback(() => {
const current = state.runList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++;
const generation = generationRef.current;
runGenerationRef.current++;
const generation = runGenerationRef.current;
executeOperation(
"workflow.runs.list",
{ cursor: current.value.nextCursor, limit: 50 },
generation,
generationRef,
runGenerationRef,
(interpreted) => {
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
},
@@ -12,6 +12,7 @@ export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
key={beat.id}
type="button"
data-active={beat.id === activeBeat}
aria-current={beat.id === activeBeat ? "step" : undefined}
onClick={() => jump(beat.id)}
>
<span>{beat.lifecycleStep}</span>
@@ -1,3 +1,4 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { presentationNodes } from "./WorkflowGraphStage.js";
type NodeSpotlightProps = {
@@ -7,22 +8,57 @@ type NodeSpotlightProps = {
const nodeDescription = (nodeId: string): string => {
if (nodeId === "review_issues") {
return "NodeUse of a typed interrupt boundary. It exposes request and resume schemas and waits for a submitted or cancelled outcome.";
return "Typed interrupt boundary. It exposes request and resume schemas, then waits for a submitted or cancelled outcome.";
}
if (nodeId === "create_issues") {
return "NodeUse that writes selected review items into the local issue-board source.";
return "Workflow step that writes selected review items into the local issue-board source.";
}
return "NodeUse in the prepared report workflow. The presentation graph is curated, but every node maps back to real workflow/run evidence.";
return "Prepared report workflow step. The presentation graph is curated, but each node maps back to workflow or run evidence.";
};
export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
const dialogRef = useRef<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
useEffect(() => {
const previouslyFocused = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
closeButtonRef.current?.focus();
return () => previouslyFocused?.focus();
}, []);
const handleKeyDown = (event: KeyboardEvent) => {
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"));
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
if (!node) return null;
return (
<aside className="node-spotlight" role="dialog" aria-modal="true" aria-label={node.label}>
<button type="button" onClick={close}>Close</button>
<p>NodeUse</p>
<aside
ref={dialogRef}
className="node-spotlight"
role="dialog"
aria-modal="true"
aria-label={node.label}
onKeyDown={handleKeyDown}
>
<button type="button" ref={closeButtonRef} onClick={close}>Close</button>
<p>Workflow node</p>
<h2>{node.label}</h2>
<p>{nodeDescription(node.id)}</p>
</aside>
@@ -25,8 +25,8 @@ export const OperationBlock = ({ event }: OperationBlockProps) => (
</section>
</div>
<footer>
<span>{event.resultingIds.deploymentId}</span>
{event.resultingIds.runId && <span>{event.resultingIds.runId}</span>}
<span>Deployment: {event.resultingIds.deploymentId}</span>
{event.resultingIds.runId && <span>Run: {event.resultingIds.runId}</span>}
</footer>
</section>
);
@@ -16,7 +16,11 @@ export const OperatorChat = ({ state }: OperatorChatProps) => (
</div>
<div className="chat-message chat-message--system">
<strong>lda.chat</strong>
<p>Replay mode is active. Live execution is available when connected.</p>
<p>
{state.playbackMode === "replay"
? "Replay mode is active. Live execution is available when connected."
: "Live execution is active. Operations are being sent to the connected workflow server."}
</p>
</div>
</aside>
);
@@ -19,7 +19,7 @@ describe("PresentationRoute", () => {
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
});
@@ -36,7 +36,7 @@ describe("PresentationRoute", () => {
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
expect(screen.getByText("NodeUse")).toBeInTheDocument();
expect(screen.getByText("Workflow node")).toBeInTheDocument();
});
it("can advance replay far enough to show a product operation block", async () => {
@@ -29,12 +29,24 @@ export const PresentationRoute = () => {
}
}, [state.beat]);
useEffect(() => {
const onHashChange = () => {
dispatch({ type: "jump_hash", hash: window.location.hash });
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target;
const isBodyEvent = target == null || target === window || target === document.body || target === document.documentElement;
if (event.key === " " || event.key === "ArrowRight") {
if (!isBodyEvent) return;
event.preventDefault();
dispatch({ type: "next" });
} else if (event.key === "ArrowLeft") {
if (!isBodyEvent) return;
event.preventDefault();
dispatch({ type: "previous" });
} else if (event.key === "Escape") {
@@ -57,6 +69,10 @@ export const PresentationRoute = () => {
}
}, [demo.state.phase, demo.state.mode, demo.start]);
useEffect(() => {
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
}, [demo.state.mode]);
return (
<main className="presentation-route" aria-label="lda.chat presentation">
<PresentationStage
@@ -28,6 +28,7 @@ export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraph
className="workflow-graph-stage__node"
data-kind={node.kind}
data-selected={selectedNodeId === node.id}
aria-pressed={selectedNodeId === node.id}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
onClick={() => selectNode(node.id)}
>
@@ -34,7 +34,7 @@ const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationS
...state,
beat,
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode,
evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
});
export const presentationReducer = (
+9 -2
View File
@@ -265,6 +265,13 @@ tbody tr:hover {
height: 34rem;
}
@media (max-height: 760px) {
.graph-content,
.workflow-graph {
height: 24rem;
}
}
.workflow-graph {
width: 100%;
border: 1px solid var(--color-border);
@@ -273,11 +280,11 @@ tbody tr:hover {
overflow: hidden;
}
section[aria-label="Lifecycle Explorer"]:has(.graph-content) {
[data-panel="lifecycle-explorer"]:has(.graph-content) {
grid-column: 1 / -1;
}
.app-layout:has(.graph-content) > section[aria-label="Source Inventory"] {
.app-layout:has(.graph-content) > [data-panel="source-inventory"] {
display: none;
}