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} loading={state.sourcesLoading}
error={state.sourceError} 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} /> <LifecycleExplorer controller={lifecycleController} />
</section> </section>
</div> </div>
@@ -8,7 +8,7 @@ type Props = {
export const SourceInventory = ({ sources, loading, error }: Props) => { export const SourceInventory = ({ sources, loading, error }: Props) => {
return ( return (
<section aria-label="Source Inventory"> <section aria-label="Source Inventory" data-panel="source-inventory">
<h2>Sources</h2> <h2>Sources</h2>
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>} {loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
{error && ( {error && (
@@ -1,16 +1,24 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js"; import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
import { DemoTimelineControls } from "./DemoTimelineControls.js"; import { DemoTimelineControls } from "./DemoTimelineControls.js";
import { DemoTimeline } from "./DemoTimeline.js"; import { DemoTimeline } from "./DemoTimeline.js";
import type { DemoTimelineController } from "./useDemoTimeline.js"; import type { DemoTimelineController } from "./useDemoTimeline.js";
const DEFAULT_REVIEW_COMMENT = "Create selected issues before the defense.";
export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => { export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
const { state } = controller; const { state } = controller;
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set()); 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 proposedIssues = controller.interruptPayload?.proposed_issues ?? [];
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]); const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
useEffect(() => {
setSelectedIds(new Set());
setComment(DEFAULT_REVIEW_COMMENT);
}, [state.mode, state.phase, controller.interruptPayload]);
return ( return (
<section aria-label="lda report workflow demo" className="demo-panel"> <section aria-label="lda report workflow demo" className="demo-panel">
<div className="demo-panel__header"> <div className="demo-panel__header">
+35 -21
View File
@@ -51,6 +51,25 @@ export type LiveStepResult = {
readonly events: ReadonlyArray<DemoEvent>; 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 = ( const eventFromResult = (
context: LiveDemoContext, context: LiveDemoContext,
stage: DemoEventStage, stage: DemoEventStage,
@@ -60,9 +79,7 @@ const eventFromResult = (
result: RpcResponse, result: RpcResponse,
runId: string | null, runId: string | null,
): DemoEvent => ({ ): DemoEvent => ({
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`, ...baseDemoEvent(context, stage, runId),
sequence: context.nextSequence,
stage,
operation, operation,
reason: result.ok ? reason : result.error.message, reason: result.ok ? reason : result.error.message,
equivalentCli: result.ok ? result.equivalentCli : null, equivalentCli: result.ok ? result.equivalentCli : null,
@@ -70,11 +87,6 @@ const eventFromResult = (
rawResponse: result.exchange.response, rawResponse: result.exchange.response,
interpreted: result.ok ? result.interpreted : null, interpreted: result.ok ? result.interpreted : null,
durationMs: result.ok ? result.durationMs : 0, durationMs: result.ok ? result.durationMs : 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId,
},
recordedAt: new Date().toISOString(),
}); });
const syntheticEvent = ( const syntheticEvent = (
@@ -85,9 +97,7 @@ const syntheticEvent = (
runId: string | null, runId: string | null,
sequenceOffset = 0, sequenceOffset = 0,
): DemoEvent => ({ ): DemoEvent => ({
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`, ...baseDemoEvent(context, stage, runId, sequenceOffset),
sequence: context.nextSequence + sequenceOffset,
stage,
operation: null, operation: null,
reason, reason,
equivalentCli: null, equivalentCli: null,
@@ -95,8 +105,6 @@ const syntheticEvent = (
rawResponse: null, rawResponse: null,
interpreted, interpreted,
durationMs: 0, durationMs: 0,
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
recordedAt: new Date().toISOString(),
}); });
const operationForStage = (stage: LiveDemoStage): string | null => { const operationForStage = (stage: LiveDemoStage): string | null => {
@@ -118,9 +126,7 @@ export const failedLiveDemoEvent = (
context: LiveDemoContext, context: LiveDemoContext,
reason: string, reason: string,
): DemoEvent => ({ ): DemoEvent => ({
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`, ...baseDemoEvent(context, "failed", context.runId),
sequence: context.nextSequence,
stage: "failed",
operation: operationForStage(context.nextStage), operation: operationForStage(context.nextStage),
reason, reason,
equivalentCli: null, equivalentCli: null,
@@ -128,11 +134,6 @@ export const failedLiveDemoEvent = (
rawResponse: null, rawResponse: null,
interpreted: null, interpreted: null,
durationMs: 0, durationMs: 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId: context.runId,
},
recordedAt: new Date().toISOString(),
}); });
export const executeLiveDemoStep = async ( export const executeLiveDemoStep = async (
@@ -238,6 +239,19 @@ export const executeLiveDemoStep = async (
}; };
} }
const detail = decodeRunDetail(result.interpreted); 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); const output = parseLdaReportOutput(detail.output);
return { return {
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)], 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); const recordEvidenceRef = useRef(recordEvidence);
recordEvidenceRef.current = recordEvidence; recordEvidenceRef.current = recordEvidence;
const inFlightRef = useRef(false); const inFlightRef = useRef(false);
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(loadCanonicalDemoRecording()); const activeRecording = useRef(loadCanonicalDemoRecording());
@@ -71,9 +72,25 @@ export const useDemoTimeline = (
const [output, setOutput] = useState<LdaReportOutput | null>(null); const [output, setOutput] = useState<LdaReportOutput | null>(null);
const [trace, setTrace] = useState<TracePage | 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 () => { const step = useCallback(async () => {
if (inFlightRef.current) return; if (inFlightRef.current) return;
if (state.appliedCount >= state.events.length && state.mode === "replay") return; if (state.appliedCount >= state.events.length && state.mode === "replay") return;
const generation = generationRef.current;
inFlightRef.current = true; inFlightRef.current = true;
setInFlight(true); setInFlight(true);
try { try {
@@ -102,6 +119,7 @@ export const useDemoTimeline = (
const approval = approvalRef.current; const approval = approvalRef.current;
approvalRef.current = null; approvalRef.current = null;
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined); const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
if (generation !== generationRef.current) return;
liveContextRef.current = result.context; liveContextRef.current = result.context;
for (const event of result.events) { for (const event of result.events) {
dispatch({ type: "append_live_event", event }); dispatch({ type: "append_live_event", event });
@@ -162,8 +180,10 @@ export const useDemoTimeline = (
dispatch({ type: "fail", message }); dispatch({ type: "fail", message });
} }
} finally { } finally {
inFlightRef.current = false; if (generation === generationRef.current) {
setInFlight(false); inFlightRef.current = false;
setInFlight(false);
}
} }
}, [state.mode, state.appliedCount, state.events, target]); }, [state.mode, state.appliedCount, state.events, target]);
@@ -177,25 +197,19 @@ export const useDemoTimeline = (
}, [state.phase, state.autoplay, state.appliedCount, step]); }, [state.phase, state.autoplay, state.appliedCount, step]);
const setMode = useCallback((mode: DemoMode) => { const setMode = useCallback((mode: DemoMode) => {
resetRuntime();
dispatch({ type: "set_mode", mode }); dispatch({ type: "set_mode", mode });
liveContextRef.current = initialLiveDemoContext; }, [resetRuntime]);
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
const start = useCallback(() => { const start = useCallback(() => {
resetRuntime();
if (state.mode === "replay") { if (state.mode === "replay") {
const recording = activeRecording.current; const recording = activeRecording.current;
dispatch({ type: "start", mode: "replay", events: recording.events }); dispatch({ type: "start", mode: "replay", events: recording.events });
} else { } else {
liveContextRef.current = initialLiveDemoContext;
dispatch({ type: "start", mode: "live", events: [] }); dispatch({ type: "start", mode: "live", events: [] });
} }
setInterruptPayload(null); }, [resetRuntime, state.mode]);
setOutput(null);
setTrace(null);
}, [state.mode]);
const pause = useCallback(() => dispatch({ type: "pause" }), []); const pause = useCallback(() => dispatch({ type: "pause" }), []);
const play = useCallback(() => dispatch({ type: "play" }), []); const play = useCallback(() => dispatch({ type: "play" }), []);
@@ -229,12 +243,9 @@ export const useDemoTimeline = (
}, []); }, []);
const restart = useCallback(() => { const restart = useCallback(() => {
resetRuntime();
dispatch({ type: "restart" }); dispatch({ type: "restart" });
liveContextRef.current = initialLiveDemoContext; }, [resetRuntime]);
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
return { return {
state, state,
@@ -96,6 +96,18 @@ describe("WorkflowGraph", () => {
expect(onSelect).toHaveBeenCalledWith("review"); 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", () => { it("highlights active node when activeNodeId is provided", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />); const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
const reviewNode = findNodeById(container, "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 { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -39,10 +39,16 @@ const nodeColor = (data: WorkflowGraphNodeData): string => {
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => { const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive; 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 ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown}
data-active={isActive} data-active={isActive}
data-node-id={data.nodeId} data-node-id={data.nodeId}
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`} 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, id: n.id,
type: "custom", type: "custom",
position: n.position, 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( const edges: Edge[] = useMemo(
@@ -64,6 +64,24 @@ describe("buildWorkflowGraph", () => {
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page"); 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", () => { it("creates edges from plan edges", () => {
const model = buildWorkflowGraph(samplePlan); const model = buildWorkflowGraph(samplePlan);
expect(model.edges.length).toBe(5); expect(model.edges.length).toBe(5);
@@ -15,6 +15,7 @@ export type WorkflowGraphNodeData = {
readonly label: string; readonly label: string;
readonly nodeRef: string | null; readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>; readonly raw: Readonly<Record<string, unknown>>;
readonly onSelect?: (nodeId: string) => void;
}; };
export type WorkflowGraphNode = { 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 === "interrupt") return (node.kind as string) ?? "Interrupt";
if (type === "foreach") return "For Each"; if (type === "foreach") return "For Each";
if (type === "join") return "Join"; 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; const nodeRef = node.node as string | undefined;
if (nodeRef) { if (nodeRef) {
const parts = nodeRef.split("."); const parts = nodeRef.split(".");
@@ -150,4 +150,55 @@ describe("LifecycleExplorer", () => {
render(<LifecycleExplorer controller={controller} />); render(<LifecycleExplorer controller={controller} />);
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible(); 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 { WorkflowGraph } from "../graph/WorkflowGraph.js";
import { buildTraceFrames } from "../execution/trace-model.js"; import { buildTraceFrames } from "../execution/trace-model.js";
import { ExecutionView } from "../execution/ExecutionView.js"; import { ExecutionView } from "../execution/ExecutionView.js";
import type { LoadState } from "./state.js";
type LifecycleExplorerProps = { type LifecycleExplorerProps = {
readonly controller: LifecycleExplorerController; readonly controller: LifecycleExplorerController;
@@ -13,18 +14,24 @@ type LifecycleExplorerProps = {
type FocusMode = "lifecycle" | "graph" | "execution" | "raw"; 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) => { export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
const { state } = controller; const { state } = controller;
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle"); const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
const artifacts = const artifacts = loadedItems(state.artifactList);
state.artifactList.phase === "loaded" ? state.artifactList.value.items : []; const deployments = loadedItems(state.deploymentList);
const deployments = const runs = loadedItems(state.runList);
state.deploymentList.phase === "loaded"
? state.deploymentList.value.items
: [];
const runs =
state.runList.phase === "loaded" ? state.runList.value.items : [];
const graphModel = useMemo(() => { const graphModel = useMemo(() => {
if (!state.artifactDetail?.plan) return null; if (!state.artifactDetail?.plan) return null;
@@ -74,6 +81,16 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
{focusMode === "lifecycle" && ( {focusMode === "lifecycle" && (
<div className="lifecycle-content"> <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 <RecordColumns
artifacts={artifacts} artifacts={artifacts}
deployments={deployments} deployments={deployments}
@@ -1,9 +1,9 @@
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js"; import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
type RecordColumnsProps = { type RecordColumnsProps = {
readonly artifacts: ArtifactSummary[]; readonly artifacts: ReadonlyArray<ArtifactSummary>;
readonly deployments: DeploymentSummary[]; readonly deployments: ReadonlyArray<DeploymentSummary>;
readonly runs: RunSummary[]; readonly runs: ReadonlyArray<RunSummary>;
readonly selectedArtifactId: string | null; readonly selectedArtifactId: string | null;
readonly selectedDeploymentId: string | null; readonly selectedDeploymentId: string | null;
readonly selectedRunId: 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 { callOperation } from "../connection/api.js";
import type { OperationName } from "../connection/contracts.js"; import type { OperationName } from "../connection/contracts.js";
import { import {
@@ -26,15 +26,7 @@ export type LifecycleExplorerController = {
export const useLifecycleExplorer = ( export const useLifecycleExplorer = (
target: string | null, target: string | null,
recordEvidence: (record: { recordEvidence: (record: EvidenceRecord) => void,
id: string;
operation: string;
label: string;
equivalentCli: string;
request: unknown;
response: unknown;
durationMs: number;
}) => void,
): LifecycleExplorerController => { ): LifecycleExplorerController => {
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState); const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
const generationRef = useRef(0); const generationRef = useRef(0);
@@ -49,8 +41,9 @@ export const useLifecycleExplorer = (
operation: OperationName, operation: OperationName,
params: unknown, params: unknown,
generation: number, generation: number,
checkGenerationRef: React.MutableRefObject<number>, checkGenerationRef: MutableRefObject<number>,
onSuccess: (interpreted: unknown) => void, onSuccess: (interpreted: unknown) => void,
onFailure?: (message: string) => void,
) => { ) => {
if (!target) return; if (!target) return;
try { try {
@@ -83,6 +76,7 @@ export const useLifecycleExplorer = (
}); });
} }
} else { } else {
onFailure?.(result.error.message);
const seq = evidenceSeqRef.current++; const seq = evidenceSeqRef.current++;
const record: EvidenceRecord = { const record: EvidenceRecord = {
id: `${operation}-${seq}`, id: `${operation}-${seq}`,
@@ -106,11 +100,14 @@ export const useLifecycleExplorer = (
}); });
} }
} catch (rpcError) { } catch (rpcError) {
if (generation !== checkGenerationRef.current) return;
const message = rpcError instanceof Error ? rpcError.message : String(rpcError);
onFailure?.(message);
dispatch({ dispatch({
type: "pushError", type: "pushError",
error: { error: {
operation, operation,
message: rpcError instanceof Error ? rpcError.message : String(rpcError), message,
timestamp: Date.now(), timestamp: Date.now(),
}, },
}); });
@@ -122,20 +119,32 @@ export const useLifecycleExplorer = (
useEffect(() => { useEffect(() => {
if (!target) return; if (!target) return;
generationRef.current++; generationRef.current++;
artifactGenerationRef.current++;
deploymentGenerationRef.current++;
runGenerationRef.current++;
const generation = generationRef.current; const generation = generationRef.current;
rawEvidenceRef.current = []; rawEvidenceRef.current = [];
dispatch({ type: "targetChanged" }); 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) => { executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(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", {}, generation, generationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(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 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) }); dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
}); });
}, [target, executeOperation]); }, [target, executeOperation]);
@@ -223,28 +232,42 @@ export const useLifecycleExplorer = (
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (!target) return; if (!target) return;
generationRef.current++; generationRef.current++;
const generation = generationRef.current; artifactGenerationRef.current++;
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => { 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) }); 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) }); 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) }); dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
}); });
}, [target, executeOperation]); }, [target, executeOperation]);
const loadMoreArtifacts = useCallback(() => { const loadMoreArtifacts = useCallback(() => {
const current = state.artifactList; const current = state.artifactList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return; if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++; artifactGenerationRef.current++;
const generation = generationRef.current; const generation = artifactGenerationRef.current;
executeOperation( executeOperation(
"workflow.artifacts.list", "workflow.artifacts.list",
{ cursor: current.value.nextCursor, limit: 50 }, { cursor: current.value.nextCursor, limit: 50 },
generation, generation,
generationRef, artifactGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) }); dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
}, },
@@ -254,13 +277,13 @@ export const useLifecycleExplorer = (
const loadMoreRuns = useCallback(() => { const loadMoreRuns = useCallback(() => {
const current = state.runList; const current = state.runList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return; if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++; runGenerationRef.current++;
const generation = generationRef.current; const generation = runGenerationRef.current;
executeOperation( executeOperation(
"workflow.runs.list", "workflow.runs.list",
{ cursor: current.value.nextCursor, limit: 50 }, { cursor: current.value.nextCursor, limit: 50 },
generation, generation,
generationRef, runGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) }); dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
}, },
@@ -12,6 +12,7 @@ export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
key={beat.id} key={beat.id}
type="button" type="button"
data-active={beat.id === activeBeat} data-active={beat.id === activeBeat}
aria-current={beat.id === activeBeat ? "step" : undefined}
onClick={() => jump(beat.id)} onClick={() => jump(beat.id)}
> >
<span>{beat.lifecycleStep}</span> <span>{beat.lifecycleStep}</span>
@@ -1,3 +1,4 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { presentationNodes } from "./WorkflowGraphStage.js"; import { presentationNodes } from "./WorkflowGraphStage.js";
type NodeSpotlightProps = { type NodeSpotlightProps = {
@@ -7,22 +8,57 @@ type NodeSpotlightProps = {
const nodeDescription = (nodeId: string): string => { const nodeDescription = (nodeId: string): string => {
if (nodeId === "review_issues") { 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") { 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) => { export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
const dialogRef = useRef<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const node = presentationNodes.find((candidate) => candidate.id === nodeId); 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; if (!node) return null;
return ( return (
<aside className="node-spotlight" role="dialog" aria-modal="true" aria-label={node.label}> <aside
<button type="button" onClick={close}>Close</button> ref={dialogRef}
<p>NodeUse</p> 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> <h2>{node.label}</h2>
<p>{nodeDescription(node.id)}</p> <p>{nodeDescription(node.id)}</p>
</aside> </aside>
@@ -25,8 +25,8 @@ export const OperationBlock = ({ event }: OperationBlockProps) => (
</section> </section>
</div> </div>
<footer> <footer>
<span>{event.resultingIds.deploymentId}</span> <span>Deployment: {event.resultingIds.deploymentId}</span>
{event.resultingIds.runId && <span>{event.resultingIds.runId}</span>} {event.resultingIds.runId && <span>Run: {event.resultingIds.runId}</span>}
</footer> </footer>
</section> </section>
); );
@@ -16,7 +16,11 @@ export const OperatorChat = ({ state }: OperatorChatProps) => (
</div> </div>
<div className="chat-message chat-message--system"> <div className="chat-message chat-message--system">
<strong>lda.chat</strong> <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> </div>
</aside> </aside>
); );
@@ -19,7 +19,7 @@ describe("PresentationRoute", () => {
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument(); 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(); 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 })); await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument(); 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 () => { it("can advance replay far enough to show a product operation block", async () => {
@@ -29,12 +29,24 @@ export const PresentationRoute = () => {
} }
}, [state.beat]); }, [state.beat]);
useEffect(() => {
const onHashChange = () => {
dispatch({ type: "jump_hash", hash: window.location.hash });
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { 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 (event.key === " " || event.key === "ArrowRight") {
if (!isBodyEvent) return;
event.preventDefault(); event.preventDefault();
dispatch({ type: "next" }); dispatch({ type: "next" });
} else if (event.key === "ArrowLeft") { } else if (event.key === "ArrowLeft") {
if (!isBodyEvent) return;
event.preventDefault(); event.preventDefault();
dispatch({ type: "previous" }); dispatch({ type: "previous" });
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
@@ -57,6 +69,10 @@ export const PresentationRoute = () => {
} }
}, [demo.state.phase, demo.state.mode, demo.start]); }, [demo.state.phase, demo.state.mode, demo.start]);
useEffect(() => {
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
}, [demo.state.mode]);
return ( return (
<main className="presentation-route" aria-label="lda.chat presentation"> <main className="presentation-route" aria-label="lda.chat presentation">
<PresentationStage <PresentationStage
@@ -28,6 +28,7 @@ export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraph
className="workflow-graph-stage__node" className="workflow-graph-stage__node"
data-kind={node.kind} data-kind={node.kind}
data-selected={selectedNodeId === node.id} data-selected={selectedNodeId === node.id}
aria-pressed={selectedNodeId === node.id}
style={{ left: `${node.x}%`, top: `${node.y}%` }} style={{ left: `${node.x}%`, top: `${node.y}%` }}
onClick={() => selectNode(node.id)} onClick={() => selectNode(node.id)}
> >
@@ -34,7 +34,7 @@ const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationS
...state, ...state,
beat, beat,
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail", chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode, evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
}); });
export const presentationReducer = ( export const presentationReducer = (
+9 -2
View File
@@ -265,6 +265,13 @@ tbody tr:hover {
height: 34rem; height: 34rem;
} }
@media (max-height: 760px) {
.graph-content,
.workflow-graph {
height: 24rem;
}
}
.workflow-graph { .workflow-graph {
width: 100%; width: 100%;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -273,11 +280,11 @@ tbody tr:hover {
overflow: hidden; overflow: hidden;
} }
section[aria-label="Lifecycle Explorer"]:has(.graph-content) { [data-panel="lifecycle-explorer"]:has(.graph-content) {
grid-column: 1 / -1; 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; display: none;
} }
+25 -15
View File
@@ -70,6 +70,11 @@ const interpretNextActions = (nextActions: {
warnings: nextActions.warnings, warnings: nextActions.warnings,
}); });
const shellArg = (value: string | number): string => {
const text = String(value);
return /^[A-Za-z0-9._/@:-]+$/.test(text) ? text : `'${text.replace(/'/g, "''")}'`;
};
/** Adapts a snake_case run detail from the server into camelCase for the browser. */ /** Adapts a snake_case run detail from the server into camelCase for the browser. */
const interpretRunDetail = (decoded: { const interpretRunDetail = (decoded: {
readonly run_id: string; readonly run_id: string;
@@ -125,7 +130,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
); );
const parts = ["uv run wf source list"]; const parts = ["uv run wf source list"];
if (p.limit != null) parts.push(`--limit ${p.limit}`); if (p.limit != null) parts.push(`--limit ${p.limit}`);
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`); if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
return parts.join(" "); return parts.join(" ");
}, },
interpret: (result): WorkflowSourcesListInterpreted => { interpret: (result): WorkflowSourcesListInterpreted => {
@@ -162,6 +167,9 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
const parts = ["uv run wf artifact list"]; const parts = ["uv run wf artifact list"];
if (p.query != null) parts.push(`--query ${shellArg(p.query)}`);
if (p.kind != null) parts.push(`--kind ${shellArg(p.kind)}`);
if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
if (p.limit != null) parts.push(`--limit ${p.limit}`); if (p.limit != null) parts.push(`--limit ${p.limit}`);
return parts.join(" "); return parts.join(" ");
}, },
@@ -196,7 +204,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
params, params,
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
return `uv run wf artifact inspect ${p.artifact_id} --version ${p.version}`; return `uv run wf artifact inspect ${shellArg(p.artifact_id)} --version ${p.version}`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync( const decoded = Schema.decodeUnknownSync(
@@ -246,7 +254,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
const p = Schema.decodeUnknownSync( const p = Schema.decodeUnknownSync(
WorkflowDeploymentsInspectPayloadSchema, WorkflowDeploymentsInspectPayloadSchema,
)(params, { onExcessProperty: "error" }); )(params, { onExcessProperty: "error" });
return `uv run wf deploy inspect ${p.deployment_id}`; return `uv run wf deploy inspect ${shellArg(p.deployment_id)}`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync( const decoded = Schema.decodeUnknownSync(
@@ -273,7 +281,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
const p = Schema.decodeUnknownSync( const p = Schema.decodeUnknownSync(
WorkflowDeploymentsValidatePayloadSchema, WorkflowDeploymentsValidatePayloadSchema,
)(params, { onExcessProperty: "error" }); )(params, { onExcessProperty: "error" });
return `uv run wf deploy validate ${p.deployment_id}`; return `uv run wf deploy validate ${shellArg(p.deployment_id)}`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync( const decoded = Schema.decodeUnknownSync(
@@ -300,6 +308,8 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
const parts = ["uv run wf run list"]; const parts = ["uv run wf run list"];
if (p.status != null) parts.push(`--status ${shellArg(p.status)}`);
if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
if (p.limit != null) parts.push(`--limit ${p.limit}`); if (p.limit != null) parts.push(`--limit ${p.limit}`);
return parts.join(" "); return parts.join(" ");
}, },
@@ -334,7 +344,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
params, params,
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
return `uv run wf run inspect ${p.run_id}`; return `uv run wf run inspect ${shellArg(p.run_id)}`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)( const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
@@ -367,7 +377,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
params, params,
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
return `uv run wf run start ${p.deployment_id} --input '<json>'`; return `uv run wf run start ${shellArg(p.deployment_id)} --input '<json>'`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)( const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
@@ -387,7 +397,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
params, params,
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
return `uv run wf run resume ${p.run_id} --payload '<json>'`; return `uv run wf run resume ${shellArg(p.run_id)} --payload '<json>'`;
}, },
interpret: (result) => { interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)( const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
@@ -407,11 +417,11 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
params, params,
{ onExcessProperty: "error" }, { onExcessProperty: "error" },
); );
return `uv run wf run trace ${p.run_id} --from ${p.trace_range.start} --limit ${p.trace_range.limit}`; return `uv run wf run trace ${shellArg(p.run_id)} --from ${p.trace_range.start} --limit ${p.trace_range.limit}`;
}, },
interpret: (result) => { interpret: (result) => {
const r = result as Record<string, unknown>; const decoded = Schema.decodeUnknownSync(WorkflowRunsTraceResultSchema)(result);
const trace = (r.trace as ReadonlyArray<Record<string, unknown>> ?? []).map((entry) => ({ const trace = decoded.trace.map((entry) => ({
nodeId: entry.node_id, nodeId: entry.node_id,
stepType: entry.step_type, stepType: entry.step_type,
outcome: entry.outcome, outcome: entry.outcome,
@@ -420,12 +430,12 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
stateChanges: entry.state_changes, stateChanges: entry.state_changes,
})); }));
return { return {
runId: r.run_id, runId: decoded.run_id,
status: r.status, status: decoded.status,
frames: trace, frames: trace,
traceStart: r.trace_start, traceStart: decoded.trace_start,
traceLimit: r.trace_limit, traceLimit: decoded.trace_limit,
traceTruncated: r.trace_truncated, traceTruncated: decoded.trace_truncated,
}; };
}, },
}, },