feat: show presentation live replay status
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
import { PresentationFooter } from "./PresentationFooter.js";
|
import { PresentationFooter } from "./PresentationFooter.js";
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
@@ -16,6 +17,11 @@ describe("PresentationFooter", () => {
|
|||||||
response: { result: { status: "completed" } },
|
response: { result: { status: "completed" } },
|
||||||
durationMs: 34,
|
durationMs: 34,
|
||||||
};
|
};
|
||||||
|
const replayHealth: PresentationTargetHealth = {
|
||||||
|
kind: "replay",
|
||||||
|
label: "Replay evidence",
|
||||||
|
detail: "reviewed recording",
|
||||||
|
};
|
||||||
render(
|
render(
|
||||||
<PresentationFooter
|
<PresentationFooter
|
||||||
location={{
|
location={{
|
||||||
@@ -25,6 +31,7 @@ describe("PresentationFooter", () => {
|
|||||||
focusPath: [],
|
focusPath: [],
|
||||||
}}
|
}}
|
||||||
evidence={[evidence]}
|
evidence={[evidence]}
|
||||||
|
targetStatus={replayHealth}
|
||||||
showEvidenceReceipt
|
showEvidenceReceipt
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import { SceneProgress } from "./SceneProgress.js";
|
import { SceneProgress } from "./SceneProgress.js";
|
||||||
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
||||||
|
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
|
||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
import type { MainLocation } from "./storyboard.js";
|
import type { MainLocation } from "./storyboard.js";
|
||||||
|
|
||||||
type PresentationFooterProps = {
|
type PresentationFooterProps = {
|
||||||
readonly location: MainLocation;
|
readonly location: MainLocation;
|
||||||
readonly evidence: readonly EvidenceRecord[];
|
readonly evidence: readonly EvidenceRecord[];
|
||||||
|
readonly targetStatus: PresentationTargetHealth;
|
||||||
readonly showEvidenceReceipt: boolean;
|
readonly showEvidenceReceipt: boolean;
|
||||||
readonly inspectEvidence: () => void;
|
readonly inspectEvidence: () => void;
|
||||||
};
|
};
|
||||||
@@ -13,11 +16,13 @@ type PresentationFooterProps = {
|
|||||||
export const PresentationFooter = ({
|
export const PresentationFooter = ({
|
||||||
location,
|
location,
|
||||||
evidence,
|
evidence,
|
||||||
|
targetStatus,
|
||||||
showEvidenceReceipt,
|
showEvidenceReceipt,
|
||||||
inspectEvidence,
|
inspectEvidence,
|
||||||
}: PresentationFooterProps) => (
|
}: PresentationFooterProps) => (
|
||||||
<footer className="presentation-footer" aria-label="presentation footer">
|
<footer className="presentation-footer" aria-label="presentation footer">
|
||||||
<SceneProgress location={location} />
|
<SceneProgress location={location} />
|
||||||
|
<PresentationTruthBadge status={targetStatus} />
|
||||||
<EvidenceReceipt
|
<EvidenceReceipt
|
||||||
records={evidence}
|
records={evidence}
|
||||||
visible={showEvidenceReceipt}
|
visible={showEvidenceReceipt}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import { resolvePresentationTarget } from "./live-target.js";
|
import { resolvePresentationTarget } from "./live-target.js";
|
||||||
|
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
|
||||||
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
|
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
|
||||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||||
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||||
@@ -52,6 +53,7 @@ export const PresentationRoute = () => {
|
|||||||
|
|
||||||
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||||
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
||||||
|
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
|
||||||
const timelineAgent = useTimelineAgent(
|
const timelineAgent = useTimelineAgent(
|
||||||
demo,
|
demo,
|
||||||
presentationTarget.mode === "live" ? "live" : "replay",
|
presentationTarget.mode === "live" ? "live" : "replay",
|
||||||
@@ -197,6 +199,7 @@ export const PresentationRoute = () => {
|
|||||||
evidence={evidence}
|
evidence={evidence}
|
||||||
timelineAgent={timelineAgent}
|
timelineAgent={timelineAgent}
|
||||||
approvalActions={approvalActions}
|
approvalActions={approvalActions}
|
||||||
|
targetStatus={targetStatus}
|
||||||
jump={handleJump}
|
jump={handleJump}
|
||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
|
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { OperatorChat } from "./OperatorChat.js";
|
|||||||
import { PresentationFooter } from "./PresentationFooter.js";
|
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 { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
import { findScene, type MainLocation } from "./storyboard.js";
|
import { findScene, type MainLocation } from "./storyboard.js";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ type PresentationStageProps = {
|
|||||||
readonly approvalActions?: DemoApprovalActions | undefined;
|
readonly approvalActions?: DemoApprovalActions | undefined;
|
||||||
readonly onApprove?: (() => void) | undefined;
|
readonly onApprove?: (() => void) | undefined;
|
||||||
readonly onDeny?: (() => void) | undefined;
|
readonly onDeny?: (() => void) | undefined;
|
||||||
|
readonly targetStatus: PresentationTargetHealth;
|
||||||
readonly jump: (location: MainLocation) => void;
|
readonly jump: (location: MainLocation) => void;
|
||||||
readonly selectNode: (nodeId: string | null) => void;
|
readonly selectNode: (nodeId: string | null) => void;
|
||||||
readonly openEvidence: () => void;
|
readonly openEvidence: () => void;
|
||||||
@@ -39,6 +41,7 @@ export const PresentationStage = ({
|
|||||||
approvalActions,
|
approvalActions,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDeny,
|
onDeny,
|
||||||
|
targetStatus,
|
||||||
jump,
|
jump,
|
||||||
selectNode,
|
selectNode,
|
||||||
openEvidence,
|
openEvidence,
|
||||||
@@ -90,6 +93,7 @@ export const PresentationStage = ({
|
|||||||
<PresentationFooter
|
<PresentationFooter
|
||||||
location={state.location}
|
location={state.location}
|
||||||
evidence={evidence}
|
evidence={evidence}
|
||||||
|
targetStatus={targetStatus}
|
||||||
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
||||||
inspectEvidence={openEvidence}
|
inspectEvidence={openEvidence}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
|
||||||
|
|
||||||
|
describe("PresentationTruthBadge", () => {
|
||||||
|
it("renders status label and detail", () => {
|
||||||
|
render(
|
||||||
|
<PresentationTruthBadge
|
||||||
|
status={{
|
||||||
|
kind: "ready",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Live target ready",
|
||||||
|
detail: "127.0.0.1:8765",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("presentation evidence mode")).toHaveAttribute("data-status", "ready");
|
||||||
|
expect(screen.getByText("Live target ready")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("127.0.0.1:8765")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
|
|
||||||
|
export const PresentationTruthBadge = ({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
readonly status: PresentationTargetHealth;
|
||||||
|
}) => (
|
||||||
|
<aside
|
||||||
|
className="presentation-truth-badge"
|
||||||
|
data-status={status.kind}
|
||||||
|
aria-label="presentation evidence mode"
|
||||||
|
>
|
||||||
|
<strong>{status.label}</strong>
|
||||||
|
<span>{status.detail}</span>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
@@ -494,6 +494,36 @@
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presentation-truth-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklch, var(--stage-surface) 88%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-truth-badge span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-truth-badge[data-status="failed"] {
|
||||||
|
border-color: color-mix(in oklch, var(--accent-amber) 70%, var(--stage-line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-truth-badge[data-status="active"],
|
||||||
|
.presentation-truth-badge[data-status="ready"] {
|
||||||
|
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-receipt:focus-visible,
|
.evidence-receipt:focus-visible,
|
||||||
.evidence-inspector button:focus-visible,
|
.evidence-inspector button:focus-visible,
|
||||||
.evidence-inspector select:focus-visible {
|
.evidence-inspector select:focus-visible {
|
||||||
|
|||||||
Reference in New Issue
Block a user