fix: address presentation review findings
This commit is contained in:
@@ -128,6 +128,34 @@ describe("useTimelineAgent", () => {
|
||||
expect(start).toHaveBeenCalledWith("live");
|
||||
});
|
||||
|
||||
it("does not advertise live launch when the timeline cannot start", () => {
|
||||
const demo = demoController({ canStart: false });
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, {
|
||||
mode: "live",
|
||||
status: readyStatus,
|
||||
}));
|
||||
|
||||
expect(result.current.canRunLive).toBe(false);
|
||||
});
|
||||
|
||||
it("uses target health for an explicit live launch regardless of selected mode", async () => {
|
||||
const start = vi.fn();
|
||||
const demo = demoController({
|
||||
state: { ...initialDemoTimelineState, mode: "replay" },
|
||||
start,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, {
|
||||
mode: "replay",
|
||||
status: readyStatus,
|
||||
}));
|
||||
|
||||
expect(result.current.canRunLive).toBe(true);
|
||||
await act(async () => result.current.runPreparedWorkflow("live"));
|
||||
|
||||
expect(start).toHaveBeenCalledWith("live");
|
||||
});
|
||||
|
||||
it("does not offer an explicit live launch when health has failed", () => {
|
||||
const start = vi.fn();
|
||||
const demo = demoController({ start });
|
||||
|
||||
@@ -91,9 +91,15 @@ export const useTimelineAgent = (
|
||||
|
||||
const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough";
|
||||
const canRun = demo.canStart && !demo.inFlight && demo.state.phase !== "running";
|
||||
const canRunLive = (options.liveTargetReady ?? (options.mode === "live" && (
|
||||
// Live capability is about the target and timeline, not the selected display
|
||||
// mode; callers can request a live run from a replay-backed slide explicitly.
|
||||
const liveTargetReady = options.liveTargetReady ?? (
|
||||
options.status.kind === "ready" || options.status.kind === "active"
|
||||
))) && !demo.inFlight && demo.state.phase !== "running";
|
||||
);
|
||||
const canRunLive = liveTargetReady
|
||||
&& demo.canStart
|
||||
&& !demo.inFlight
|
||||
&& demo.state.phase !== "running";
|
||||
|
||||
const runPreparedWorkflow = useCallback(async (requestedMode?: TimelineAgentMode) => {
|
||||
const mode = requestedMode ?? modeLabel;
|
||||
|
||||
@@ -111,6 +111,15 @@ describe("PresentationRoute", () => {
|
||||
15000,
|
||||
);
|
||||
|
||||
it("exposes an application-owned readiness signal for rehearsal capture", async () => {
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
render(<PresentationRoute />);
|
||||
|
||||
const route = await screen.findByRole("main", { name: /lda\.chat presentation/i });
|
||||
await waitFor(() => expect(route).toHaveAttribute("data-presentation-ready", "true"));
|
||||
});
|
||||
|
||||
const visualRouteContracts = [
|
||||
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", primary: "thesis opening" },
|
||||
{ hash: "#scene/lifecycle/draft", heading: "Workflow Lifecycle", primary: "workflow lifecycle rail" },
|
||||
|
||||
@@ -43,6 +43,29 @@ export const PresentationRoute = () => {
|
||||
{ type: "jump_hash", hash: initialHash },
|
||||
),
|
||||
);
|
||||
const [presentationReady, setPresentationReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
setPresentationReady(false);
|
||||
// Font metrics and two paint opportunities let measured diagrams settle
|
||||
// before the rehearsal runner accepts the route as screenshot-ready.
|
||||
const markReady = async () => {
|
||||
await (document.fonts?.ready ?? Promise.resolve());
|
||||
if (cancelled) return;
|
||||
firstFrame = window.requestAnimationFrame(() => {
|
||||
secondFrame = window.requestAnimationFrame(() => setPresentationReady(true));
|
||||
});
|
||||
};
|
||||
void markReady();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(firstFrame);
|
||||
window.cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [state.location]);
|
||||
|
||||
const recording = useMemo(() => loadCanonicalDemoRecording(), []);
|
||||
const replayEvidence = useMemo(() => projectRecordingToEvidence(recording), [recording]);
|
||||
@@ -260,7 +283,12 @@ export const PresentationRoute = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation" data-motion={state.motionDisabled ? "disabled" : "enabled"}>
|
||||
<main
|
||||
className="presentation-route"
|
||||
aria-label="lda.chat presentation"
|
||||
data-motion={state.motionDisabled ? "disabled" : "enabled"}
|
||||
data-presentation-ready={presentationReady ? "true" : "false"}
|
||||
>
|
||||
<PresentationCanvas>
|
||||
<PresentationStage
|
||||
state={state}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SceneProgress } from "./SceneProgress.js";
|
||||
import { SceneProgress, shouldShowBeatCounter } from "./SceneProgress.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("SceneProgress", () => {
|
||||
it("uses scene metadata for a single-beat counter", () => {
|
||||
expect(shouldShowBeatCounter({ alwaysShowBeatCounter: true }, 1)).toBe(true);
|
||||
expect(shouldShowBeatCounter({ alwaysShowBeatCounter: false }, 1)).toBe(false);
|
||||
expect(shouldShowBeatCounter(undefined, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows scene and beat position for architecture/runtime", () => {
|
||||
const location: MainLocation = {
|
||||
kind: "main",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { findScene, mainScenes, type MainLocation } from "./storyboard.js";
|
||||
import type { SceneDefinition } from "./storyboard.js";
|
||||
|
||||
type SceneProgressProps = {
|
||||
readonly location: MainLocation;
|
||||
};
|
||||
|
||||
export const shouldShowBeatCounter = (
|
||||
scene: Pick<SceneDefinition, "alwaysShowBeatCounter"> | undefined,
|
||||
totalBeats: number,
|
||||
): boolean => totalBeats > 1 || scene?.alwaysShowBeatCounter === true;
|
||||
|
||||
export const SceneProgress = ({ location }: SceneProgressProps) => {
|
||||
const scene = findScene(location.sceneId);
|
||||
const sceneIndex = mainScenes.findIndex((s) => s.id === location.sceneId);
|
||||
@@ -18,7 +24,7 @@ export const SceneProgress = ({ location }: SceneProgressProps) => {
|
||||
{sceneIndex + 1} / {totalScenes}
|
||||
</span>
|
||||
)}
|
||||
{(totalBeats > 1 || location.sceneId === "agent-handoff") && (
|
||||
{shouldShowBeatCounter(scene, totalBeats) && (
|
||||
<span className="scene-progress__beat">
|
||||
{beatIndex >= 0 ? beatIndex + 1 : 1} / {totalBeats}
|
||||
</span>
|
||||
|
||||
+34
-4
@@ -1,15 +1,16 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { findBeat, findScene } from "../storyboard.js";
|
||||
import { PreparedAuthoringLifecycleScene } from "./PreparedAuthoringLifecycleScene.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const renderBeat = (beatId: string) => {
|
||||
const renderBeat = (beatId: string, onAdvance?: () => void) => {
|
||||
const scene = findScene("prepared-lifecycle");
|
||||
const beat = findBeat("prepared-lifecycle", beatId);
|
||||
if (!scene || !beat) throw new Error(`missing prepared-lifecycle/${beatId}`);
|
||||
return render(<PreparedAuthoringLifecycleScene scene={scene} beat={beat} />);
|
||||
return render(<PreparedAuthoringLifecycleScene scene={scene} beat={beat} onAdvance={onAdvance} />);
|
||||
};
|
||||
|
||||
describe("PreparedAuthoringLifecycleScene", () => {
|
||||
@@ -85,6 +86,35 @@ describe("PreparedAuthoringLifecycleScene", () => {
|
||||
expect(workspace.querySelectorAll('[data-visual-role="lifecycle-primary"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("projects a custom discover submission into the prepared conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBeat("discover");
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
|
||||
await user.type(input, "Inspect the report source first.");
|
||||
await user.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
const conversation = screen.getByRole("log", { name: "prepared authoring conversation" });
|
||||
expect(within(conversation).getByText("Inspect the report source first.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["draft", true],
|
||||
["artifact", true],
|
||||
["validate", false],
|
||||
["deployment", false],
|
||||
] as const)("%s submission advances only when its beat owns the transition", async (beatId, advances) => {
|
||||
const user = userEvent.setup();
|
||||
const onAdvance = vi.fn();
|
||||
renderBeat(beatId, onAdvance);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
|
||||
if (beatId === "validate") await user.type(input, "Review the validation result.");
|
||||
await user.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
expect(onAdvance).toHaveBeenCalledTimes(advances ? 1 : 0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["discover", "Discover"],
|
||||
["draft", "Draft"],
|
||||
|
||||
@@ -63,6 +63,9 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance }: Prep
|
||||
onDraftChange={(draft) => dispatch({ type: "draft_edited", draft })}
|
||||
onSubmit={(submittedText) => {
|
||||
dispatch({ type: "draft_edited", draft: submittedText });
|
||||
if (beatId === "discover") {
|
||||
dispatch({ type: "discover_submitted" });
|
||||
}
|
||||
if (beatId === "draft") {
|
||||
dispatch({ type: "draft_submitted" });
|
||||
onAdvance?.();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "../../components/ui/button.js";
|
||||
import { Textarea } from "../../components/ui/textarea.js";
|
||||
import { AuthoringConversation } from "./AuthoringConversation.js";
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Scene9MessageProjection,
|
||||
Scene9SubmittedOverrides,
|
||||
} from "./scene9-message-state.js";
|
||||
import { PREPARED_COMPOSER_HELP, usePreparedComposerSubmit } from "./usePreparedComposerSubmit.js";
|
||||
|
||||
export type PresentationAssistantPaneProps = {
|
||||
readonly phase: AuthoringPhaseId;
|
||||
@@ -52,16 +53,10 @@ export const PresentationAssistantPane = ({
|
||||
setDraft(nextDraft);
|
||||
onDraftChange(nextDraft);
|
||||
};
|
||||
const submit = (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (canSubmit) onSubmit(draft);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
const { submit, handleKeyDown } = usePreparedComposerSubmit(
|
||||
canSubmit,
|
||||
() => onSubmit(draft),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -104,7 +99,7 @@ export const PresentationAssistantPane = ({
|
||||
</Button>
|
||||
</div>
|
||||
<p id="scene9-authoring-message-help" className="presentation-assistant-pane__composer-help">
|
||||
Shift+Enter adds a new line. This is a deterministic prepared replay, not a live model request.
|
||||
{PREPARED_COMPOSER_HELP}
|
||||
</p>
|
||||
{runRequested !== null ? (
|
||||
<p role="status" className="presentation-assistant-pane__run-status">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Button } from "../../components/ui/button.js";
|
||||
import { Textarea } from "../../components/ui/textarea.js";
|
||||
import { AuthoringConversation } from "./AuthoringConversation.js";
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
type Scene8EntryAction,
|
||||
type Scene8EntryState,
|
||||
} from "./scene8-entry-state.js";
|
||||
import { PREPARED_COMPOSER_HELP, usePreparedComposerSubmit } from "./usePreparedComposerSubmit.js";
|
||||
|
||||
type Scene8ChatEntryProps = {
|
||||
readonly state: Scene8EntryState;
|
||||
@@ -17,11 +17,10 @@ type Scene8ChatEntryProps = {
|
||||
export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
|
||||
const submitted = state.phase === "submitted";
|
||||
const canSubmit = canSubmitScene8Entry(state);
|
||||
|
||||
const submit = (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (canSubmit) dispatch({ type: "submit" });
|
||||
};
|
||||
const { submit, handleKeyDown } = usePreparedComposerSubmit(
|
||||
canSubmit,
|
||||
() => dispatch({ type: "submit" }),
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -43,12 +42,7 @@ export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
|
||||
value={state.draft}
|
||||
disabled={submitted}
|
||||
onChange={(event) => dispatch({ type: "draft_changed", draft: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-describedby="scene8-authoring-request-help"
|
||||
/>
|
||||
<div className="agent-handoff-scene__composer-actions">
|
||||
@@ -57,7 +51,7 @@ export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
<p id="scene8-authoring-request-help" className="agent-handoff-scene__composer-help">
|
||||
Shift+Enter adds a new line. This is a deterministic prepared replay, not a live model request.
|
||||
{PREPARED_COMPOSER_HELP}
|
||||
</p>
|
||||
</form>
|
||||
{submitted ? (
|
||||
|
||||
@@ -205,4 +205,20 @@ describe("projectPreparedAuthoringThread", () => {
|
||||
canonical.filter((message) => message.id.endsWith("-tools")),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps each staged override scoped to its phase", () => {
|
||||
const messages = projectPreparedAuthoringThread("deployment", undefined, {
|
||||
validate: "Edited validation request",
|
||||
deployment: "Edited deployment request",
|
||||
});
|
||||
const userText = messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.parts[0]?.type === "text" ? message.parts[0].text : "");
|
||||
|
||||
expect(userText).toEqual([
|
||||
"We need to author a report workflow for the lda_report scenario. What sources and capabilities are available?",
|
||||
"Edited validation request",
|
||||
"Edited deployment request",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,28 +281,40 @@ export const authoringToolGroupId = (phase: AuthoringPhaseId): string =>
|
||||
export const projectPreparedAuthoringThread = (
|
||||
throughPhase: AuthoringPhaseId = "deployment",
|
||||
requestOverride?: string,
|
||||
requestOverrides?: Readonly<Partial<Record<"validate" | "deployment", string>>>,
|
||||
requestOverrides?: Readonly<Partial<Record<"discover" | "validate" | "deployment", string>>>,
|
||||
): readonly AgentMessage[] => {
|
||||
const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase);
|
||||
if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`);
|
||||
|
||||
let requestReplaced = false;
|
||||
const phaseOverrideApplied = new Set<AuthoringPhaseId>();
|
||||
return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => {
|
||||
const phaseOverride =
|
||||
phase.phase === "validate"
|
||||
phase.phase === "discover"
|
||||
? requestOverrides?.discover
|
||||
: phase.phase === "validate"
|
||||
? requestOverrides?.validate
|
||||
: phase.phase === "deployment"
|
||||
? requestOverrides?.deployment
|
||||
: undefined;
|
||||
const conversation = phase.conversation.map((turn, index) => {
|
||||
const shouldReplaceRequest =
|
||||
turn.role === "user" &&
|
||||
(phaseOverride !== undefined || (requestOverride !== undefined && !requestReplaced));
|
||||
if (shouldReplaceRequest && phaseOverride === undefined) requestReplaced = true;
|
||||
let replacement: string | undefined;
|
||||
if (turn.role === "user" && phaseOverride !== undefined && !phaseOverrideApplied.has(phase.phase)) {
|
||||
replacement = phaseOverride;
|
||||
phaseOverrideApplied.add(phase.phase);
|
||||
} else if (
|
||||
turn.role === "user"
|
||||
&& phaseOverride === undefined
|
||||
&& requestOverride !== undefined
|
||||
&& !requestReplaced
|
||||
) {
|
||||
replacement = requestOverride;
|
||||
requestReplaced = true;
|
||||
}
|
||||
return agentTextMessage(
|
||||
`authoring-${phase.phase}-message-${index}`,
|
||||
turn.role,
|
||||
shouldReplaceRequest ? (phaseOverride ?? requestOverride ?? turn.text) : turn.text,
|
||||
replacement ?? turn.text,
|
||||
);
|
||||
});
|
||||
const groupId = authoringToolGroupId(phase.phase);
|
||||
|
||||
@@ -69,6 +69,19 @@ describe("scene 9 staged message state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stores discover submissions under the discover destination", () => {
|
||||
const state = scene9MessageReducer(initialScene9MessageState, {
|
||||
type: "draft_edited",
|
||||
draft: "Inspect the report source first.",
|
||||
});
|
||||
|
||||
expect(scene9MessageReducer(state, { type: "discover_submitted" })).toEqual({
|
||||
draft: state.draft,
|
||||
submittedOverrides: { discover: state.draft },
|
||||
runRequested: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores blank submits and keeps duplicate submits idempotent", () => {
|
||||
const blank = { ...initialScene9MessageState, draft: " \n\t" };
|
||||
expect(scene9MessageReducer(blank, { type: "draft_submitted" })).toBe(blank);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthoringPhaseId } from "./authoring-recording.js";
|
||||
|
||||
export type Scene9MessagePhase = AuthoringPhaseId;
|
||||
export type Scene9DestinationPhase = "validate" | "deployment";
|
||||
export type Scene9DestinationPhase = "discover" | "validate" | "deployment";
|
||||
|
||||
export const SCENE9_PHASE_PROMPTS: Readonly<Record<Scene9MessagePhase, string>> = {
|
||||
discover: "",
|
||||
@@ -38,6 +38,7 @@ export const initialScene9MessageState: Scene9MessageState = {
|
||||
|
||||
export type Scene9MessageAction =
|
||||
| { readonly type: "draft_edited"; readonly draft: string }
|
||||
| { readonly type: "discover_submitted" }
|
||||
| { readonly type: "draft_submitted" }
|
||||
| { readonly type: "artifact_submitted" }
|
||||
| { readonly type: "run_requested" };
|
||||
@@ -66,6 +67,8 @@ export const scene9MessageReducer = (
|
||||
switch (action.type) {
|
||||
case "draft_edited":
|
||||
return state.runRequested === null ? { ...state, draft: action.draft } : state;
|
||||
case "discover_submitted":
|
||||
return submitOverride(state, "discover");
|
||||
case "draft_submitted":
|
||||
return submitOverride(state, "validate");
|
||||
case "artifact_submitted":
|
||||
@@ -92,7 +95,7 @@ export const projectScene9Message = (
|
||||
placeholder: SCENE9_PHASE_PLACEHOLDERS[phase],
|
||||
});
|
||||
|
||||
/** Keeps the transcript-facing map limited to the two staged handoff destinations. */
|
||||
/** Returns the transcript-facing request overrides by destination phase. */
|
||||
export const projectScene9SubmittedOverrides = (
|
||||
state: Scene9MessageState,
|
||||
): Scene9SubmittedOverrides => state.submittedOverrides;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
|
||||
type PreparedComposerSubmitHandlers = {
|
||||
readonly submit: (event?: FormEvent<HTMLFormElement>) => void;
|
||||
readonly handleKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
};
|
||||
|
||||
export const PREPARED_COMPOSER_HELP =
|
||||
"Shift+Enter adds a new line. This is a deterministic prepared replay, not a live model request.";
|
||||
|
||||
/** Shares the prepared-replay composer keyboard and form submission contract. */
|
||||
export const usePreparedComposerSubmit = (
|
||||
canSubmit: boolean,
|
||||
onSubmit: () => void,
|
||||
): PreparedComposerSubmitHandlers => {
|
||||
const submit = (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (canSubmit) onSubmit();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return { submit, handleKeyDown };
|
||||
};
|
||||
@@ -172,7 +172,13 @@ describe("AssistantOperatorThread", () => {
|
||||
|
||||
it("can keep a first-stage group anchored at the start of the transcript", async () => {
|
||||
const setScrollTop = vi.fn();
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollTop");
|
||||
const descriptors = {
|
||||
scrollTop: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollTop"),
|
||||
scrollHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollHeight"),
|
||||
clientHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "clientHeight"),
|
||||
offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop"),
|
||||
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
|
||||
};
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
id: "assistant-start-scroll-text",
|
||||
@@ -192,10 +198,14 @@ describe("AssistantOperatorThread", () => {
|
||||
];
|
||||
|
||||
try {
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => 0,
|
||||
set: setScrollTop,
|
||||
Object.defineProperties(HTMLDivElement.prototype, {
|
||||
scrollTop: { configurable: true, get: () => 0, set: setScrollTop },
|
||||
scrollHeight: { configurable: true, get: () => 400 },
|
||||
clientHeight: { configurable: true, get: () => 80 },
|
||||
});
|
||||
Object.defineProperties(HTMLElement.prototype, {
|
||||
offsetTop: { configurable: true, get: () => 120 },
|
||||
offsetHeight: { configurable: true, get: () => 40 },
|
||||
});
|
||||
render(
|
||||
<AssistantOperatorThread
|
||||
@@ -209,10 +219,15 @@ describe("AssistantOperatorThread", () => {
|
||||
|
||||
await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0));
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", originalDescriptor);
|
||||
} else {
|
||||
delete (HTMLDivElement.prototype as { scrollTop?: number }).scrollTop;
|
||||
for (const [name, descriptor] of Object.entries(descriptors)) {
|
||||
const prototype = name === "offsetTop" || name === "offsetHeight"
|
||||
? HTMLElement.prototype
|
||||
: HTMLDivElement.prototype;
|
||||
if (descriptor) {
|
||||
Object.defineProperty(prototype, name, descriptor);
|
||||
} else {
|
||||
delete (prototype as unknown as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,34 @@ import { describe, expect, it } from "vitest";
|
||||
const css = readFileSync(join(import.meta.dirname, "presentation.css"), "utf8").replace(/\r\n/g, "\n");
|
||||
const demoWorkflowCss = readFileSync(join(import.meta.dirname, "styles", "demo-workflow.css"), "utf8").replace(/\r\n/g, "\n");
|
||||
|
||||
const cssBlocks = (source: string, selector: string): readonly string[] => {
|
||||
const blocks: string[] = [];
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < source.length) {
|
||||
const selectorStart = source.indexOf(selector, searchFrom);
|
||||
if (selectorStart < 0) break;
|
||||
const openingBrace = source.indexOf("{", selectorStart);
|
||||
if (openingBrace < 0) break;
|
||||
|
||||
let depth = 0;
|
||||
for (let index = openingBrace; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
if (source[index] !== "}") continue;
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
blocks.push(source.slice(openingBrace + 1, index));
|
||||
searchFrom = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (searchFrom <= selectorStart) break;
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const cssBlock = (source: string, selector: string): string | undefined =>
|
||||
cssBlocks(source, selector).at(-1);
|
||||
|
||||
describe("presentation.css", () => {
|
||||
it("keeps the demo footer rail compact and removes the old launch control", () => {
|
||||
const railBlock = css.match(/\.presentation-demo-rail\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
@@ -80,16 +108,30 @@ describe("presentation.css", () => {
|
||||
|
||||
it("bounds Scene 9 conversation scrolling and recenters Scene 8 at compact stage widths", () => {
|
||||
expect(css.match(/\.presentation-stage\[data-scene-view="agent"\] \.presentation-stage__primary\s*\{/g)).toHaveLength(2);
|
||||
expect(css).toMatch(
|
||||
/\.presentation-stage__primary > \.agent-handoff-scene\s*\{[\s\S]*?margin-inline:\s*auto;/,
|
||||
expect(cssBlocks(css, ".presentation-stage__primary > .agent-handoff-scene")
|
||||
.some((body) => body.includes("margin-inline: auto;"))).toBe(true);
|
||||
expect(cssBlocks(css, ".agent-handoff-scene__intro")
|
||||
.some((body) => body.includes("width: min(calc(100% - 3rem), 72rem);"))).toBe(true);
|
||||
expect(cssBlocks(css, ".agent-handoff-scene__composer")
|
||||
.some((body) => body.includes("width: min(calc(100% - 3rem), 72rem);"))).toBe(true);
|
||||
const conversation = cssBlock(
|
||||
css,
|
||||
'.prepared-lifecycle-scene[data-presentation-surface="editorial"] .presentation-assistant-pane__conversation',
|
||||
);
|
||||
expect(css).toMatch(/\.agent-handoff-scene__intro\s*\{[\s\S]*?width:\s*min\(calc\(100% - 3rem\), 72rem\);/);
|
||||
expect(css).toMatch(/\.agent-handoff-scene__composer\s*\{[\s\S]*?width:\s*min\(calc\(100% - 3rem\), 72rem\);/);
|
||||
expect(css).toMatch(
|
||||
/\.prepared-lifecycle-scene\[data-presentation-surface="editorial"\] \.presentation-assistant-pane__conversation\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;[\s\S]*?overflow:\s*auto;/,
|
||||
expect(conversation).toContain("flex: 1 1 auto;");
|
||||
expect(conversation).toContain("min-height: 0;");
|
||||
expect(conversation).toContain("overflow: auto;");
|
||||
const compactAgentPrimary = cssBlock(
|
||||
cssBlock(css, "@media (max-width: 1100px)") ?? "",
|
||||
'.presentation-stage[data-scene-view="agent"] .presentation-stage__primary',
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/@media \(max-width: 1100px\)[\s\S]*?\.presentation-stage\[data-scene-view="agent"\] \.presentation-stage__primary\s*\{[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;/,
|
||||
expect(compactAgentPrimary).toContain("align-items: center;");
|
||||
expect(compactAgentPrimary).toContain("justify-content: center;");
|
||||
});
|
||||
|
||||
it("does not keep an empty findings campaign-strip ruleset", () => {
|
||||
expect(css).not.toContain(
|
||||
'.evaluation-board[data-evaluation-focus="findings"] .evaluation-board__campaign-strip {\n /*',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -188,11 +188,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.evaluation-board[data-evaluation-focus="findings"] .evaluation-board__campaign-strip {
|
||||
/* grid-template-columns: minmax(12rem, 0.7fr) minmax(20rem, 1.3fr); */
|
||||
/* difference between two beats -> shifting */
|
||||
}
|
||||
|
||||
.evaluation-board[data-evaluation-focus="cohort"] .evaluation-board__cohort {
|
||||
border-right: 0;
|
||||
padding-block: 1.35rem 0.8rem;
|
||||
|
||||
@@ -40,6 +40,7 @@ export type SceneDefinition = {
|
||||
readonly evidencePointer: string;
|
||||
readonly view: SceneView;
|
||||
readonly beats: readonly SceneBeatDefinition[];
|
||||
readonly alwaysShowBeatCounter?: boolean;
|
||||
};
|
||||
|
||||
const defineScenes = <const Scenes extends readonly SceneDefinition[]>(scenes: Scenes): Scenes => scenes;
|
||||
@@ -158,6 +159,7 @@ export const mainScenes = defineScenes([
|
||||
claimClass: "implemented",
|
||||
evidencePointer: "Constrained demo agent and prepared replay recipe",
|
||||
view: "agent",
|
||||
alwaysShowBeatCounter: true,
|
||||
beats: [
|
||||
sceneBeat("request", "Operator request", "A thin agent interface receives the report request.", { chatMode: "hidden", chatTheme: "light" }),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user