feat: add stable presentation demo footer rail

This commit is contained in:
lda
2026-07-12 15:28:18 +07:00 Verified
parent ad1db7d7f4
commit a074a24d45
7 changed files with 284 additions and 22 deletions
@@ -0,0 +1,89 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
import { PresentationDemoRail } from "./PresentationDemoRail.js";
afterEach(() => cleanup());
const readyAction: DemoChromePresentation = {
kind: "action",
mode: "live",
label: "Run prepared workflow",
status: {
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
},
canRun: true,
canRetry: true,
};
describe("PresentationDemoRail", () => {
it("renders no content when demo chrome is hidden", () => {
const { container } = render(
<PresentationDemoRail presentation={{ kind: "hidden" }} retryHealth={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders the target badge and runs a healthy live action", () => {
const runPreparedWorkflow = vi.fn(async () => {});
render(
<PresentationDemoRail
presentation={readyAction}
runPreparedWorkflow={runPreparedWorkflow}
retryHealth={vi.fn()}
/>,
);
const rail = screen.getByTestId("presentation-demo-rail");
expect(within(rail).getByLabelText("presentation evidence mode")).toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Run prepared workflow" }));
expect(runPreparedWorkflow).toHaveBeenCalledWith("live");
});
it("uses replay action and retry when live health has failed", () => {
const retryHealth = vi.fn();
render(
<PresentationDemoRail
presentation={{
...readyAction,
mode: "replay",
label: "Play replay walkthrough",
status: {
kind: "failed",
target: "http://127.0.0.1:8765/rpc",
label: "Replay fallback",
detail: "connection refused",
},
}}
retryHealth={retryHealth}
/>,
);
const rail = screen.getByTestId("presentation-demo-rail");
expect(within(rail).getByRole("button", { name: "Play replay walkthrough" })).toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Retry live service" }));
expect(retryHealth).toHaveBeenCalledOnce();
});
it.each([
["running", "Running workflow..."],
["paused", "Run paused - review required"],
["resuming", "Resuming workflow..."],
["completed", "Run complete"],
] as const)("renders %s as status content without a disabled run button", (kind, label) => {
render(
<PresentationDemoRail
presentation={{ kind, label } as DemoChromePresentation}
retryHealth={vi.fn()}
/>,
);
const rail = screen.getByTestId("presentation-demo-rail");
expect(within(rail).getByRole("status")).toHaveTextContent(label);
expect(within(rail).queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
import type { JSX } from "react";
import type { TimelineAgentMode } from "../demo/agent/timelineAgent.js";
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
type PresentationDemoRailProps = {
readonly presentation: DemoChromePresentation;
readonly runPreparedWorkflow?: ((mode: TimelineAgentMode) => Promise<void>) | undefined;
readonly retryHealth: () => void;
};
export const PresentationDemoRail = ({
presentation,
runPreparedWorkflow,
retryHealth,
}: PresentationDemoRailProps): JSX.Element | null => {
if (presentation.kind === "hidden") return null;
return (
<div
className="presentation-demo-rail"
data-demo-rail={presentation.kind}
data-testid="presentation-demo-rail"
>
{presentation.kind === "action" ? (
<>
<PresentationTruthBadge status={presentation.status} />
<div className="presentation-demo-rail__actions">
<button
type="button"
className="presentation-demo-rail__primary"
onClick={() => void runPreparedWorkflow?.(presentation.mode)}
disabled={!presentation.canRun || runPreparedWorkflow === undefined}
>
{presentation.label}
</button>
{presentation.canRetry ? (
<button
type="button"
className="presentation-demo-rail__retry"
onClick={retryHealth}
>
Retry live service
</button>
) : null}
</div>
</>
) : (
<span className="presentation-demo-rail__status" role="status" aria-live="polite">
{presentation.label}
</span>
)}
</div>
);
};
@@ -1,7 +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 type { DemoChromePresentation } from "./presentation-demo-chrome.js";
import { PresentationFooter } from "./PresentationFooter.js"; import { PresentationFooter } from "./PresentationFooter.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -17,10 +17,8 @@ describe("PresentationFooter", () => {
response: { result: { status: "completed" } }, response: { result: { status: "completed" } },
durationMs: 34, durationMs: 34,
}; };
const replayHealth: PresentationTargetHealth = { const hiddenRail: DemoChromePresentation = {
kind: "replay", kind: "hidden",
label: "Replay evidence",
detail: "reviewed recording",
}; };
render( render(
<PresentationFooter <PresentationFooter
@@ -31,7 +29,8 @@ describe("PresentationFooter", () => {
focusPath: [], focusPath: [],
}} }}
evidence={[evidence]} evidence={[evidence]}
targetStatus={replayHealth} demoRail={hiddenRail}
retryHealth={vi.fn()}
showEvidenceReceipt showEvidenceReceipt
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
/>, />,
@@ -52,11 +51,8 @@ describe("PresentationFooter", () => {
focusPath: [], focusPath: [],
}} }}
evidence={[]} evidence={[]}
targetStatus={{ demoRail={{ kind: "hidden" }}
kind: "replay", retryHealth={vi.fn()}
label: "Replay evidence",
detail: "reviewed recording",
}}
showEvidenceReceipt={false} showEvidenceReceipt={false}
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
/>, />,
@@ -65,4 +61,32 @@ describe("PresentationFooter", () => {
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i }); const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
expect(within(footer).queryByLabelText("presentation evidence mode")).not.toBeInTheDocument(); expect(within(footer).queryByLabelText("presentation evidence mode")).not.toBeInTheDocument();
}); });
it("renders exactly one healthy demo rail for a demo location", () => {
render(
<PresentationFooter
location={{ kind: "main", sceneId: "agent-handoff", beatId: "request", focusPath: [] }}
evidence={[]}
demoRail={{
kind: "action",
mode: "live",
label: "Run prepared workflow",
status: {
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
},
canRun: true,
canRetry: true,
}}
retryHealth={vi.fn()}
showEvidenceReceipt={false}
inspectEvidence={vi.fn()}
/>,
);
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
expect(screen.getByText("Live target ready")).toBeInTheDocument();
});
}); });
@@ -1,15 +1,17 @@
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import type { TimelineAgentMode } from "../demo/agent/timelineAgent.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 { DemoChromePresentation } from "./presentation-demo-chrome.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js"; import { PresentationDemoRail } from "./PresentationDemoRail.js";
import { isDemoChromeScene } from "./presentation-demo-chrome.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 demoRail: DemoChromePresentation;
readonly runPreparedWorkflow?: ((mode: TimelineAgentMode) => Promise<void>) | undefined;
readonly retryHealth: () => void;
readonly showEvidenceReceipt: boolean; readonly showEvidenceReceipt: boolean;
readonly inspectEvidence: () => void; readonly inspectEvidence: () => void;
}; };
@@ -17,13 +19,19 @@ type PresentationFooterProps = {
export const PresentationFooter = ({ export const PresentationFooter = ({
location, location,
evidence, evidence,
targetStatus, demoRail,
runPreparedWorkflow,
retryHealth,
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} />
{isDemoChromeScene(location.sceneId) && <PresentationTruthBadge status={targetStatus} />} <PresentationDemoRail
presentation={demoRail}
runPreparedWorkflow={runPreparedWorkflow}
retryHealth={retryHealth}
/>
<EvidenceReceipt <EvidenceReceipt
records={evidence} records={evidence}
visible={showEvidenceReceipt} visible={showEvidenceReceipt}
@@ -271,13 +271,13 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
}); });
it("renders the Scene 8 composer without a workflow run action", async () => { it("renders the Scene 8 composer with one stable footer workflow action", async () => {
window.location.hash = "#scene/agent-handoff/request"; window.location.hash = "#scene/agent-handoff/request";
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
expect(await screen.findByRole("textbox", { name: /authoring request/i })).toBeInTheDocument(); expect(await screen.findByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run prepared workflow" })).not.toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Run prepared workflow" })).toHaveLength(1);
}); });
it("uses stored target for live presentation mode", async () => { it("uses stored target for live presentation mode", async () => {
@@ -286,7 +286,7 @@ describe("PresentationRoute", () => {
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(2); expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(3);
}); });
it("exposes an explicit live launch on the Scene 10 operation beat", async () => { it("exposes an explicit live launch on the Scene 10 operation beat", async () => {
@@ -353,7 +353,7 @@ describe("PresentationRoute", () => {
expect(await screen.findByText(/Live target is ready/i)).toBeInTheDocument(); expect(await screen.findByText(/Live target is ready/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Run prepared workflow" })).not.toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "Run prepared workflow" })).toHaveLength(1);
expect(mockedCallOperation).toHaveBeenCalledWith("workflow.health", "http://127.0.0.1:8765/rpc", {}); expect(mockedCallOperation).toHaveBeenCalledWith("workflow.health", "http://127.0.0.1:8765/rpc", {});
}); });
@@ -11,6 +11,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 { PresentationTargetHealth } from "./presentation-target-status.js"; import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { demoChromeFor } from "./presentation-demo-chrome.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";
@@ -58,6 +59,20 @@ export const PresentationStage = ({
const composition = compositionForState(state); const composition = compositionForState(state);
const isMainScene = state.location.kind === "main"; const isMainScene = state.location.kind === "main";
// Keep the footer projection pure and derive it from the same state the scene consumes.
const demoRail = isMainScene
? demoChromeFor({
sceneId: state.location.sceneId,
phase: demo.state.phase,
mode: demo.state.mode,
inFlight: demo.inFlight,
approvalState: approvalActions?.state ?? "ready",
targetStatus,
liveTargetReady,
canRun: timelineAgent?.canRun ?? false,
canRunLive: timelineAgent?.canRunLive ?? false,
})
: { kind: "hidden" as const };
const activeSceneView = isMainScene const activeSceneView = isMainScene
? findScene(state.location.sceneId)?.view ?? "unknown" ? findScene(state.location.sceneId)?.view ?? "unknown"
: "discussion"; : "discussion";
@@ -104,7 +119,9 @@ export const PresentationStage = ({
<PresentationFooter <PresentationFooter
location={state.location} location={state.location}
evidence={evidence} evidence={evidence}
targetStatus={targetStatus} demoRail={demoRail}
runPreparedWorkflow={timelineAgent?.runPreparedWorkflow}
retryHealth={retryHealth}
showEvidenceReceipt={composition.evidencePresentation !== "hidden"} showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
inspectEvidence={openEvidence} inspectEvidence={openEvidence}
/> />
@@ -1028,6 +1028,75 @@
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line)); border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
} }
.presentation-demo-rail {
min-width: min(22rem, 42vw);
min-height: 1.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
}
.presentation-demo-rail__actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.presentation-demo-rail button {
border: 1px solid var(--stage-line);
border-radius: 0.35rem;
padding: 0.34rem 0.55rem;
background: var(--stage-surface);
color: var(--text-primary);
font: 600 0.68rem/1 var(--font-interface);
white-space: nowrap;
}
.presentation-demo-rail__primary {
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line)) !important;
background: color-mix(in oklch, var(--accent-cyan) 14%, var(--stage-surface)) !important;
}
.presentation-demo-rail button:hover:not(:disabled) {
border-color: var(--accent-cyan);
}
.presentation-demo-rail button:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.presentation-demo-rail button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.presentation-demo-rail__status {
color: var(--text-secondary);
font: 600 0.68rem/1 var(--font-mono);
white-space: nowrap;
}
@media (max-width: 640px) {
.presentation-demo-rail {
min-width: 0;
flex: 1 1 auto;
justify-content: flex-start;
overflow: hidden;
}
.presentation-demo-rail .presentation-truth-badge {
max-width: 40%;
}
.presentation-demo-rail__actions {
min-width: 0;
overflow: hidden;
}
}
.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 {