feat: add stable presentation demo footer rail
This commit is contained in:
@@ -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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -17,10 +17,8 @@ describe("PresentationFooter", () => {
|
||||
response: { result: { status: "completed" } },
|
||||
durationMs: 34,
|
||||
};
|
||||
const replayHealth: PresentationTargetHealth = {
|
||||
kind: "replay",
|
||||
label: "Replay evidence",
|
||||
detail: "reviewed recording",
|
||||
const hiddenRail: DemoChromePresentation = {
|
||||
kind: "hidden",
|
||||
};
|
||||
render(
|
||||
<PresentationFooter
|
||||
@@ -31,7 +29,8 @@ describe("PresentationFooter", () => {
|
||||
focusPath: [],
|
||||
}}
|
||||
evidence={[evidence]}
|
||||
targetStatus={replayHealth}
|
||||
demoRail={hiddenRail}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt
|
||||
inspectEvidence={vi.fn()}
|
||||
/>,
|
||||
@@ -52,11 +51,8 @@ describe("PresentationFooter", () => {
|
||||
focusPath: [],
|
||||
}}
|
||||
evidence={[]}
|
||||
targetStatus={{
|
||||
kind: "replay",
|
||||
label: "Replay evidence",
|
||||
detail: "reviewed recording",
|
||||
}}
|
||||
demoRail={{ kind: "hidden" }}
|
||||
retryHealth={vi.fn()}
|
||||
showEvidenceReceipt={false}
|
||||
inspectEvidence={vi.fn()}
|
||||
/>,
|
||||
@@ -65,4 +61,32 @@ describe("PresentationFooter", () => {
|
||||
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
||||
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 { TimelineAgentMode } from "../demo/agent/timelineAgent.js";
|
||||
import { SceneProgress } from "./SceneProgress.js";
|
||||
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
||||
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
import { isDemoChromeScene } from "./presentation-demo-chrome.js";
|
||||
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
|
||||
type PresentationFooterProps = {
|
||||
readonly location: MainLocation;
|
||||
readonly evidence: readonly EvidenceRecord[];
|
||||
readonly targetStatus: PresentationTargetHealth;
|
||||
readonly demoRail: DemoChromePresentation;
|
||||
readonly runPreparedWorkflow?: ((mode: TimelineAgentMode) => Promise<void>) | undefined;
|
||||
readonly retryHealth: () => void;
|
||||
readonly showEvidenceReceipt: boolean;
|
||||
readonly inspectEvidence: () => void;
|
||||
};
|
||||
@@ -17,13 +19,19 @@ type PresentationFooterProps = {
|
||||
export const PresentationFooter = ({
|
||||
location,
|
||||
evidence,
|
||||
targetStatus,
|
||||
demoRail,
|
||||
runPreparedWorkflow,
|
||||
retryHealth,
|
||||
showEvidenceReceipt,
|
||||
inspectEvidence,
|
||||
}: PresentationFooterProps) => (
|
||||
<footer className="presentation-footer" aria-label="presentation footer">
|
||||
<SceneProgress location={location} />
|
||||
{isDemoChromeScene(location.sceneId) && <PresentationTruthBadge status={targetStatus} />}
|
||||
<PresentationDemoRail
|
||||
presentation={demoRail}
|
||||
runPreparedWorkflow={runPreparedWorkflow}
|
||||
retryHealth={retryHealth}
|
||||
/>
|
||||
<EvidenceReceipt
|
||||
records={evidence}
|
||||
visible={showEvidenceReceipt}
|
||||
|
||||
@@ -271,13 +271,13 @@ describe("PresentationRoute", () => {
|
||||
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";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
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 () => {
|
||||
@@ -286,7 +286,7 @@ describe("PresentationRoute", () => {
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
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 () => {
|
||||
@@ -353,7 +353,7 @@ describe("PresentationRoute", () => {
|
||||
|
||||
expect(await screen.findByText(/Live target is ready/i)).toBeInTheDocument();
|
||||
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", {});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
import { demoChromeFor } from "./presentation-demo-chrome.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import { findScene, type MainLocation } from "./storyboard.js";
|
||||
|
||||
@@ -58,6 +59,20 @@ export const PresentationStage = ({
|
||||
const composition = compositionForState(state);
|
||||
|
||||
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
|
||||
? findScene(state.location.sceneId)?.view ?? "unknown"
|
||||
: "discussion";
|
||||
@@ -104,7 +119,9 @@ export const PresentationStage = ({
|
||||
<PresentationFooter
|
||||
location={state.location}
|
||||
evidence={evidence}
|
||||
targetStatus={targetStatus}
|
||||
demoRail={demoRail}
|
||||
runPreparedWorkflow={timelineAgent?.runPreparedWorkflow}
|
||||
retryHealth={retryHealth}
|
||||
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
||||
inspectEvidence={openEvidence}
|
||||
/>
|
||||
|
||||
@@ -1028,6 +1028,75 @@
|
||||
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-inspector button:focus-visible,
|
||||
.evidence-inspector select:focus-visible {
|
||||
|
||||
Reference in New Issue
Block a user