feat: replace presentation evidence drawer

This commit is contained in:
lda
2026-07-06 04:05:52 +07:00 Verified
parent edf983019d
commit c1bde6191a
6 changed files with 191 additions and 164 deletions
@@ -1,27 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EvidenceRecord } from "../app/state.js";
import { EvidenceDrawer } from "./EvidenceDrawer.js";
afterEach(() => cleanup());
const record: EvidenceRecord = {
id: "demo-run-start",
operation: "workflow.runs.start",
label: "Start run",
equivalentCli: "uv run wf run start lda_report_case_study.default",
request: { deployment_id: "lda_report_case_study.default" },
response: { result: { status: "interrupted" } },
durationMs: 88,
};
describe("EvidenceDrawer", () => {
it("renders current evidence records and can close", () => {
const close = vi.fn();
render(<EvidenceDrawer records={[record]} mode="receipt" close={close} />);
expect(screen.getByRole("complementary", { name: /presentation evidence/i })).toBeInTheDocument();
expect(screen.getByText(/workflow.runs.start/i)).toBeInTheDocument();
expect(screen.getByText(/interrupted/i)).toBeInTheDocument();
});
});
@@ -1,34 +0,0 @@
import type { EvidenceRecord } from "../app/state.js";
import { formatJson } from "./format.js";
import type { EvidencePresentation } from "./storyboard.js";
type EvidenceDrawerProps = {
readonly records: readonly EvidenceRecord[];
readonly mode: EvidencePresentation;
readonly close: () => void;
};
export const EvidenceDrawer = ({ records, mode, close }: EvidenceDrawerProps) => {
if (mode === "hidden") return null;
return (
<aside className="evidence-drawer" data-mode={mode} aria-label="presentation evidence">
<header>
<h2>Evidence</h2>
<button type="button" onClick={close}>Close</button>
</header>
{records.length === 0 ? (
<p>No live evidence captured in this view yet. Replay operation blocks still show recorded evidence.</p>
) : (
records.map((record) => (
<article key={record.id}>
<h3>{record.operation}</h3>
<p>{record.label}</p>
<pre><code>{record.equivalentCli}</code></pre>
<pre><code>{formatJson(record.response)}</code></pre>
</article>
))
)}
</aside>
);
};
@@ -1,79 +1,68 @@
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { PresentationRoute } from "./PresentationRoute.js";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
beforeAll(() => {
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.DOMRect = {
fromRect: () => ({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON() {},
}),
} as unknown as typeof DOMRect;
});
afterEach(() => cleanup());
describe("PresentationRoute", () => {
it("renders the presentation stage entry point", () => {
render(<PresentationRoute />);
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Thesis/ })).toBeInTheDocument();
});
it("starts from a scene hash and advances with keyboard", async () => {
window.location.hash = "#scene/agent-handoff/request";
render(<PresentationRoute />);
expect(screen.getByRole("heading", { name: /Agent Handoff/i })).toBeInTheDocument();
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByText(/The interface delegates durable work to lda\.chat/i)).toBeInTheDocument();
});
it("renders audience progress chrome without rail or mode label", () => {
render(<PresentationRoute />);
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
expect(screen.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
expect(screen.getByLabelText("scene position")).toBeInTheDocument();
});
it("shows node spotlight when a graph node is selected", async () => {
window.location.hash = "#scene/workflow-demo/graph";
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
expect(screen.getByText("Workflow node")).toBeInTheDocument();
});
it("can advance replay far enough to show a product operation block", async () => {
window.location.hash = "#scene/workflow-demo/operation";
render(<PresentationRoute />);
expect(await screen.findByText(/workflow.runs.start/i)).toBeInTheDocument();
});
it("shows resume and trace operation blocks for later beats", async () => {
window.location.hash = "#scene/interrupt-evidence/resume";
render(<PresentationRoute />);
expect(await screen.findByText(/workflow.runs.resume/i)).toBeInTheDocument();
});
it("opens a positioning branch via hash and returns to the parent scene first beat", async () => {
window.location.hash = "#discuss/hosted-automation";
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /return to positioning/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /return to positioning/i }));
expect(window.location.hash).toBe("#scene/positioning/landscape");
});
it("uses the parent scene as return location for a directly linked branch", async () => {
window.location.hash = "#discuss/mcp-agent-scale";
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /return to positioning/i }));
expect(window.location.hash).toBe("#scene/positioning/landscape");
});
it("renders stable chat, primary, evidence, and progress regions", () => {
it("renders stable chat, primary, progress, and transient evidence surfaces", async () => {
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(screen.getByLabelText(/agent chat region/i)).toBeInTheDocument();
expect(screen.getByLabelText(/primary presentation region/i)).toBeInTheDocument();
expect(screen.getByLabelText(/evidence region/i)).toBeInTheDocument();
expect(screen.getByLabelText("scene position")).toBeInTheDocument();
expect(screen.getByLabelText(/presentation footer/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/evidence region/i)).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument();
});
it("shows a receipt without auto-opening evidence on an evidence beat", async () => {
window.location.hash = "#scene/interrupt-evidence/trace";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument();
});
it("opens the inspector from an explicit operation action", async () => {
const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
await user.click(await screen.findByRole("button", { name: /view raw evidence/i }));
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument();
});
it("closes the inspector when navigation moves to another beat", async () => {
const user = userEvent.setup();
window.location.hash = "#scene/workflow-demo/operation";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
await user.click(await screen.findByRole("button", { name: /view raw evidence/i }));
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /close evidence/i }));
expect(screen.queryByRole("dialog", { name: /evidence inspector/i })).not.toBeInTheDocument();
});
});
@@ -3,9 +3,9 @@ import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import { SceneBody } from "./SceneBody.js";
import { DiscussionPanel } from "./DiscussionPanel.js";
import { EvidenceDrawer } from "./EvidenceDrawer.js";
import { EvidenceInspector } from "./evidence/EvidenceInspector.js";
import { OperatorChat } from "./OperatorChat.js";
import { SceneProgress } from "./SceneProgress.js";
import { PresentationFooter } from "./PresentationFooter.js";
import type { PresentationState } from "./presentation-state.js";
import { compositionForState } from "./presentation-state.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
@@ -78,12 +78,19 @@ export const PresentationStage = ({
/>
)}
</section>
<aside className="presentation-stage__evidence" aria-label="evidence region">
<EvidenceDrawer records={evidence} mode={composition.evidencePresentation} close={closeOverlay} />
</aside>
{state.location.kind === "main" && (
<SceneProgress location={state.location} />
<PresentationFooter
location={state.location}
evidence={evidence}
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
inspectEvidence={openEvidence}
/>
)}
<EvidenceInspector
records={evidence}
open={composition.evidencePresentation === "inspector"}
onClose={closeOverlay}
/>
</div>
</LayoutGroup>
</LazyMotion>
@@ -17,23 +17,28 @@
--success: oklch(0.7 0.16 145);
--failure: oklch(0.65 0.2 25);
--chat-width: 18rem;
--evidence-width: 0rem;
}
.presentation-stage {
height: 100dvh;
--presentation-footer-height: 2.5rem;
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: grid;
grid-template:
"chat primary evidence" minmax(0, 1fr) /
var(--chat-width) minmax(0, 1fr) var(--evidence-width);
"chat primary" minmax(0, 1fr)
"footer footer" auto /
var(--chat-width) minmax(0, 1fr);
}
/* Chat modes */
.presentation-stage[data-chat-mode="hidden"] {
grid-template:
"primary evidence" minmax(0, 1fr) /
minmax(0, 1fr) var(--evidence-width);
"primary" minmax(0, 1fr)
"footer" auto /
minmax(0, 1fr);
}
.presentation-stage[data-chat-mode="hidden"] .presentation-stage__chat {
@@ -44,18 +49,8 @@
--chat-width: 16rem;
}
/* Evidence modes */
.presentation-stage[data-evidence-mode="peek"] {
--evidence-width: 20rem;
}
.presentation-stage[data-evidence-mode="open"] {
--evidence-width: 24rem;
}
.presentation-stage__chat,
.presentation-stage__primary,
.presentation-stage__evidence {
.presentation-stage__primary {
min-width: 0;
min-height: 0;
overflow: hidden;
@@ -337,18 +332,19 @@
z-index: 20;
}
.evidence-drawer {
height: 100%;
overflow-y: auto;
padding: 0.75rem;
background: oklch(0.13 0.025 250);
border-left: 1px solid oklch(0.34 0.04 250);
}
.presentation-stage__chat { grid-area: chat; }
.presentation-stage__primary { grid-area: primary; }
.evidence-drawer pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 0.8rem;
.presentation-footer {
grid-area: footer;
min-height: var(--presentation-footer-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.35rem 1rem;
border-top: 1px solid var(--stage-line);
overflow: hidden;
}
.scene-progress {
@@ -368,6 +364,100 @@
opacity: 0.8;
}
.presentation-footer .scene-progress {
flex: 0 0 auto;
padding: 0;
}
.evidence-receipt {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
border: 0;
background: transparent;
color: var(--text-secondary);
font: 600 0.72rem/1.2 var(--font-evidence, monospace);
cursor: pointer;
}
.evidence-receipt > :first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.evidence-receipt:disabled {
cursor: default;
opacity: 0.65;
}
.evidence-receipt:focus-visible,
.evidence-inspector button:focus-visible,
.evidence-inspector select:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
.evidence-inspector-layer {
box-sizing: border-box;
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 2.5rem 5%;
background: oklch(0.08 0.02 250 / 0.68);
}
.evidence-inspector {
box-sizing: border-box;
width: min(70cqi, 56rem);
max-width: calc(100% - 2rem);
max-height: calc(100% - var(--presentation-footer-height));
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--stage-line);
background: var(--stage-surface);
color: var(--text-primary);
}
.evidence-inspector__body {
min-height: 0;
overflow: auto;
padding: 1rem;
}
.evidence-inspector pre {
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@container presentation-canvas (max-width: 1080px) {
.presentation-stage {
grid-template:
"primary" minmax(0, 1fr)
"footer" auto /
minmax(0, 1fr);
}
.presentation-stage__chat {
position: absolute;
inset: 0 auto var(--presentation-footer-height, 2.5rem) 0;
width: min(18rem, 44%);
z-index: 20;
}
.presentation-stage[data-evidence-presentation="inspector"] .presentation-stage__chat {
visibility: hidden;
pointer-events: none;
}
}
.discussion-panel {
padding: 1rem;
border: 1px solid oklch(0.36 0.04 250);
@@ -28,4 +28,6 @@
background: var(--color-editorial-paper);
color: var(--color-editorial-ink);
font-family: var(--font-interface);
container-name: presentation-canvas;
container-type: inline-size;
}