feat: replace presentation evidence drawer
This commit is contained in:
@@ -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 userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { PresentationRoute } from "./PresentationRoute.js";
|
|
||||||
|
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());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe("PresentationRoute", () => {
|
describe("PresentationRoute", () => {
|
||||||
it("renders the presentation stage entry point", () => {
|
it("renders stable chat, primary, progress, and transient evidence surfaces", async () => {
|
||||||
render(<PresentationRoute />);
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
|
||||||
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", () => {
|
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
expect(screen.getByLabelText(/agent chat region/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/agent chat region/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/primary presentation region/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/primary presentation region/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/evidence region/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/presentation footer/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("scene position")).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 type { AgentMessage } from "../demo/agent/events.js";
|
||||||
import { SceneBody } from "./SceneBody.js";
|
import { SceneBody } from "./SceneBody.js";
|
||||||
import { DiscussionPanel } from "./DiscussionPanel.js";
|
import { DiscussionPanel } from "./DiscussionPanel.js";
|
||||||
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
import { EvidenceInspector } from "./evidence/EvidenceInspector.js";
|
||||||
import { OperatorChat } from "./OperatorChat.js";
|
import { OperatorChat } from "./OperatorChat.js";
|
||||||
import { SceneProgress } from "./SceneProgress.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 { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
@@ -78,12 +78,19 @@ export const PresentationStage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<aside className="presentation-stage__evidence" aria-label="evidence region">
|
|
||||||
<EvidenceDrawer records={evidence} mode={composition.evidencePresentation} close={closeOverlay} />
|
|
||||||
</aside>
|
|
||||||
{state.location.kind === "main" && (
|
{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>
|
</div>
|
||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
|
|||||||
@@ -17,23 +17,28 @@
|
|||||||
--success: oklch(0.7 0.16 145);
|
--success: oklch(0.7 0.16 145);
|
||||||
--failure: oklch(0.65 0.2 25);
|
--failure: oklch(0.65 0.2 25);
|
||||||
--chat-width: 18rem;
|
--chat-width: 18rem;
|
||||||
--evidence-width: 0rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentation-stage {
|
.presentation-stage {
|
||||||
height: 100dvh;
|
--presentation-footer-height: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template:
|
grid-template:
|
||||||
"chat primary evidence" minmax(0, 1fr) /
|
"chat primary" minmax(0, 1fr)
|
||||||
var(--chat-width) minmax(0, 1fr) var(--evidence-width);
|
"footer footer" auto /
|
||||||
|
var(--chat-width) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat modes */
|
/* Chat modes */
|
||||||
.presentation-stage[data-chat-mode="hidden"] {
|
.presentation-stage[data-chat-mode="hidden"] {
|
||||||
grid-template:
|
grid-template:
|
||||||
"primary evidence" minmax(0, 1fr) /
|
"primary" minmax(0, 1fr)
|
||||||
minmax(0, 1fr) var(--evidence-width);
|
"footer" auto /
|
||||||
|
minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentation-stage[data-chat-mode="hidden"] .presentation-stage__chat {
|
.presentation-stage[data-chat-mode="hidden"] .presentation-stage__chat {
|
||||||
@@ -44,18 +49,8 @@
|
|||||||
--chat-width: 16rem;
|
--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__chat,
|
||||||
.presentation-stage__primary,
|
.presentation-stage__primary {
|
||||||
.presentation-stage__evidence {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -337,18 +332,19 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-drawer {
|
.presentation-stage__chat { grid-area: chat; }
|
||||||
height: 100%;
|
.presentation-stage__primary { grid-area: primary; }
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: oklch(0.13 0.025 250);
|
|
||||||
border-left: 1px solid oklch(0.34 0.04 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
.evidence-drawer pre {
|
.presentation-footer {
|
||||||
white-space: pre-wrap;
|
grid-area: footer;
|
||||||
overflow-wrap: anywhere;
|
min-height: var(--presentation-footer-height);
|
||||||
font-size: 0.8rem;
|
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 {
|
.scene-progress {
|
||||||
@@ -368,6 +364,100 @@
|
|||||||
opacity: 0.8;
|
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 {
|
.discussion-panel {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid oklch(0.36 0.04 250);
|
border: 1px solid oklch(0.36 0.04 250);
|
||||||
|
|||||||
@@ -28,4 +28,6 @@
|
|||||||
background: var(--color-editorial-paper);
|
background: var(--color-editorial-paper);
|
||||||
color: var(--color-editorial-ink);
|
color: var(--color-editorial-ink);
|
||||||
font-family: var(--font-interface);
|
font-family: var(--font-interface);
|
||||||
|
container-name: presentation-canvas;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user