fix: address presentation review findings

This commit is contained in:
lda
2026-07-13 07:12:45 +07:00 Verified
parent a16e0933e2
commit ab943deed5
25 changed files with 339 additions and 92 deletions
+14 -11
View File
@@ -204,8 +204,8 @@ Presentation visual audit, July 11:
## Completed: Presentation Recomposition And Authoring Story ## Completed: Presentation Recomposition And Authoring Story
Scenes 8 and 9 are now implemented as authentic external-agent request and Scenes 8 and 9 now present an external-agent request through a scripted
factual prepared-authoring proof. Scene 8 is one local request-to-first-turn presentation surface and factual prepared-authoring proof. Scene 8 is one local request-to-first-turn
beat; its obsolete `/handoff` route fails closed. Scene 9 keeps a persistent beat; its obsolete `/handoff` route fails closed. Scene 9 keeps a persistent
prepared assistant pane beside five factual phase visuals in an adaptive prepared assistant pane beside five factual phase visuals in an adaptive
approximately 35/65 split. The obsolete receipt/trace modal and lower chat dock approximately 35/65 split. The obsolete receipt/trace modal and lower chat dock
@@ -244,8 +244,9 @@ Recommended next visual slices:
[`presentation evaluation and closing`](superpowers/specs/2026-07-10-presentation-evaluation-closing-design.md). [`presentation evaluation and closing`](superpowers/specs/2026-07-10-presentation-evaluation-closing-design.md).
Implementation: Implementation:
[`presentation evaluation and closing plan`](historical/superpowers/plans/2026-07-10-presentation-evaluation-closing.md). [`presentation evaluation and closing plan`](historical/superpowers/plans/2026-07-10-presentation-evaluation-closing.md).
6. Completed: Presentation agent authoring story rebuilds Scenes 8 and 9 as 6. Completed: Presentation agent authoring story rebuilds Scenes 8 and 9 as a
authentic external-agent handoff and factual prepared-authoring proof. scripted surface representing an external-agent handoff and factual
prepared-authoring proof.
Implementation: Implementation:
[`presentation agent authoring story plan`](historical/superpowers/plans/2026-07-11-presentation-agent-authoring-story.md). [`presentation agent authoring story plan`](historical/superpowers/plans/2026-07-11-presentation-agent-authoring-story.md).
7. Completed: Scene 1 now introduces the agent-shaped product goal through 7. Completed: Scene 1 now introduces the agent-shaped product goal through
@@ -261,7 +262,7 @@ Recommended next visual slices:
Implementation: Implementation:
[`defense presentation rehearsal`](historical/superpowers/plans/2026-07-12-defense-presentation-rehearsal.md). [`defense presentation rehearsal`](historical/superpowers/plans/2026-07-12-defense-presentation-rehearsal.md).
9. Completed: Scenes 7-9 now use one dominant factual artifact per beat on the 9. Completed: Scenes 7-9 now use one dominant factual artifact per beat on the
Editorial Canvas: authoring/repair evidence, an authentic light agent chat, Editorial Canvas: authoring/repair evidence, a scripted light agent chat,
and a light prepared lifecycle canvas with synchronized secondary chat. and a light prepared lifecycle canvas with synchronized secondary chat.
Implementation: Implementation:
[`Scenes 7-9 editorial focal proof`](historical/superpowers/plans/2026-07-12-scenes-7-9-editorial-focal-proof.md). [`Scenes 7-9 editorial focal proof`](historical/superpowers/plans/2026-07-12-scenes-7-9-editorial-focal-proof.md).
@@ -287,12 +288,14 @@ separate activity after these surfaces are stable.
turns, and Deployment Send records a truthful local run request without turns, and Deployment Send records a truthful local run request without
execution or RPC. Implementation: execution or RPC. Implementation:
[`Scene 9 staged message box`](historical/superpowers/plans/2026-07-12-scene-9-staged-message-box.md). [`Scene 9 staged message box`](historical/superpowers/plans/2026-07-12-scene-9-staged-message-box.md).
The boundary is explicit: Scenes 1012 own run activation, approval, The boundary is explicit: the compact demo footer may start the prepared run
resume, output, and trace evidence. from Scenes 812, while Scenes 1012 own run, decision, resume, output, and
3. **Completed: Live/replay truth and run activation:** Scene 10 exposes the trace evidence.
prepared-run action outside the hidden chat, retries the existing health 3. **Completed: Live/replay truth and run activation:** the compact footer rail
probe, starts live execution explicitly, and keeps direct links and failed across Scenes 812 exposes the prepared-run action, retries the existing
services on truthful replay evidence. Implementation: health probe, starts live execution explicitly, and keeps direct links and
failed services on truthful replay evidence. Scene 10 remains the semantic
start of execution evidence. Implementation:
[`presentation live/replay activation`](historical/superpowers/plans/2026-07-12-presentation-live-replay-activation.md). [`presentation live/replay activation`](historical/superpowers/plans/2026-07-12-presentation-live-replay-activation.md).
4. **Completed: Scene 11 compression:** reduce the typed-human-boundary scene to two 4. **Completed: Scene 11 compression:** reduce the typed-human-boundary scene to two
beats: interrupt context and approval decision. Cancellation remains a beats: interrupt context and approval decision. Cancellation remains a
@@ -20,7 +20,7 @@ can be shown after selecting replay with the runbook's session-storage switch.
| `positioning/landscape` | Related systems solve different parts of the tool, script, workflow, and agent problem. | Related-systems landscape | Problem contracts | hidden | replay-only; Thesis Positioning and Related Systems | `positioning/landscape` | | `positioning/landscape` | Related systems solve different parts of the tool, script, workflow, and agent problem. | Related-systems landscape | Problem contracts | hidden | replay-only; Thesis Positioning and Related Systems | `positioning/landscape` |
| `positioning/lda-position` | lda.chat occupies the typed, provider-neutral workflow substrate niche for external agents. | lda.chat position map | Related-systems landscape | hidden | replay-only; Thesis Positioning and Related Systems | `positioning/lda-position` | | `positioning/lda-position` | lda.chat occupies the typed, provider-neutral workflow substrate niche for external agents. | lda.chat position map | Related-systems landscape | hidden | replay-only; Thesis Positioning and Related Systems | `positioning/lda-position` |
| `planner-runtime/planner` | An external planner proposes and revises workflow structure. | Planner side of boundary | Typed operations | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/planner` | | `planner-runtime/planner` | An external planner proposes and revises workflow structure. | Planner side of boundary | Typed operations | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/planner` |
| `planner-runtime/runtime` | The runtime validates, executes, records, and resumes the proposal deterministically. | Runtime side of boundary | Lifecycle records | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/runtime` | | `planner-runtime/runtime` | For fixed handler results, the core validates, executes, records, and resumes the proposal deterministically. | Runtime side of boundary | Lifecycle records | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/runtime` |
| `planner-runtime/boundary` | Typed CLI and JSON-RPC operations make the planner/runtime boundary explicit. | Boundary diagram | CLI and RPC surface | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/boundary` | | `planner-runtime/boundary` | Typed CLI and JSON-RPC operations make the planner/runtime boundary explicit. | Boundary diagram | CLI and RPC surface | hidden | replay-only; Thesis Architecture Overview | `planner-runtime/boundary` |
| `lifecycle/draft` | Draft is mutable iterative authoring state. | Lifecycle stage: Draft | State transition strip | hidden | replay-only; Thesis Workflow Lifecycle | `lifecycle/draft` | | `lifecycle/draft` | Draft is mutable iterative authoring state. | Lifecycle stage: Draft | State transition strip | hidden | replay-only; Thesis Workflow Lifecycle | `lifecycle/draft` |
| `lifecycle/artifact` | Artifact is the immutable saved workflow definition. | Lifecycle stage: Artifact | State transition strip | hidden | replay-only; Thesis Workflow Lifecycle | `lifecycle/artifact` | | `lifecycle/artifact` | Artifact is the immutable saved workflow definition. | Lifecycle stage: Artifact | State transition strip | hidden | replay-only; Thesis Workflow Lifecycle | `lifecycle/artifact` |
+1 -1
View File
@@ -51,7 +51,7 @@ pnpm --filter @lda/console build
3. 1280x720 uses a 1280x720 logical canvas 3. 1280x720 uses a 1280x720 logical canvas
4. Ratios outside 4:3 through 16:9 letterbox at the nearest supported ratio 4. Ratios outside 4:3 through 16:9 letterbox at the nearest supported ratio
5. Warm Editorial Canvas unchanged across Scene 1 and Scene 6 5. Warm Editorial Canvas unchanged across Scene 1 and Scene 6
6. No scene rail, replay label, discussion button, or presenter controls 6. Non-demo routes have no demo rail, replay label, discussion button, or presenter controls; Scenes 8-12 retain the approved compact demo footer rail
7. Scene 6 root has one primary figure and no competing card grid 7. Scene 6 root has one primary figure and no competing card grid
8. Runtime & providers expands in place with breadcrumb 8. Runtime & providers expands in place with breadcrumb
9. Configured providers expands second level 9. Configured providers expands second level
@@ -26,9 +26,10 @@ when `wf-rpc-server` is running.
### Launch surface ### Launch surface
Expose a compact prepared-run control on Scene 10's `operation` beat. It is a Expose one compact prepared-run control in `PresentationFooter` throughout
scene-owned control, not a reintroduced chat rail. It must remain visible while Scenes 8-12. It is demo chrome, not a reintroduced chat rail or a scene-owned
the target is checking, ready, failed, or the live run is active. launch panel. Scene 10 remains the semantic execution start even when the
presenter starts or prepares the run from an earlier demo scene.
The control communicates the current action: The control communicates the current action:
@@ -83,8 +84,8 @@ discussion views should not gain a persistent live-service badge or run action.
## Acceptance Criteria ## Acceptance Criteria
1. With `wf-rpc-server` and the web server running, Scene 10's operation beat 1. With `wf-rpc-server` and the web server running, the compact footer rail on
visibly exposes `Run prepared workflow` and starts a live timeline. Scenes 8-12 exposes `Run prepared workflow` and starts a live timeline.
2. A live run records deployment inspection, run start, interrupt, resume, and 2. A live run records deployment inspection, run start, interrupt, resume, and
trace evidence using the existing `callOperation` path. trace evidence using the existing `callOperation` path.
3. The typed approval form remains the only way to provide the resume decision; 3. The typed approval form remains the only way to provide the resume decision;
@@ -97,8 +98,9 @@ discussion views should not gain a persistent live-service badge or run action.
recovered with retry, without reloading the page or reconnecting manually. recovered with retry, without reloading the page or reconnecting manually.
7. The launch control cannot issue duplicate starts while an operation is in 7. The launch control cannot issue duplicate starts while an operation is in
flight or a live run is already active. flight or a live run is already active.
8. Scene 8 and Scene 9 remain deterministic authoring replay and do not call 8. Scene 8 chat Send and Scene 9 staged messages remain deterministic authoring
workflow authoring RPC operations. replay and do not call workflow authoring RPC operations. Their separate
footer control may start the prepared run.
## Verification ## Verification
@@ -44,8 +44,10 @@ The old five-step phase rail is removed from Scene 8. The lifecycle sequence is
shown by Scene 9, where the authoring canvas is the primary artifact and the shown by Scene 9, where the authoring canvas is the primary artifact and the
conversation becomes a secondary modal. conversation becomes a secondary modal.
The standalone `Run prepared workflow` action is removed from Scene 8. The The standalone in-scene `Run prepared workflow` action is removed from Scene 8.
workflow-run action belongs to Slice 3 and will be exposed in Scene 10. The later demo-chrome slice owns one compact footer rail across Scenes 8-12.
That separate footer may expose the prepared-run action, while chat Send remains
local and never starts a workflow.
### Chat Surface Boundary ### Chat Surface Boundary
@@ -77,7 +79,8 @@ On the Scene 8 request beat, render:
- a composer with the request text prefilled: - a composer with the request text prefilled:
`We need to author a report workflow for the lda_report scenario. What sources and capabilities are available?`; `We need to author a report workflow for the lda_report scenario. What sources and capabilities are available?`;
- an enabled Send button; - an enabled Send button;
- no workflow operation, run ID, deployment ID, or live target action. - no workflow operation, run ID, deployment ID, or live target action inside
the chat surface. The separate demo footer rail may show target and run state.
The prefilled text is editable. The scene does not require the presenter to The prefilled text is editable. The scene does not require the presenter to
retype the request during the defense. retype the request during the defense.
@@ -110,8 +113,9 @@ The Scene 8 entry state is local presentation state. Reloading the request beat
returns to the empty composer. The canonical authoring recording remains the returns to the empty composer. The canonical authoring recording remains the
source for all revealed turns. source for all revealed turns.
Scene 8 must work with no workflow server and must not change the live/replay Scene 8 chat must work with no workflow server and must not change live/replay
target badge. The target badge remains scoped by the later demo truth slice. state. The later demo truth slice owns the separate footer badge and launch
control across the demo arc.
### Responsive Behavior ### Responsive Behavior
+9 -2
View File
@@ -2,7 +2,8 @@
param( param(
[string]$BaseUrl = "http://127.0.0.1:5173", [string]$BaseUrl = "http://127.0.0.1:5173",
[string]$OutputRoot = "web/apps/console/.visual-smoke/rehearsal", [string]$OutputRoot = "web/apps/console/.visual-smoke/rehearsal",
[string[]]$Viewports = @("1280,720", "1024,768") [string[]]$Viewports = @("1280,720", "1024,768"),
[string]$ReadySelector = 'main[data-presentation-ready="true"]'
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -40,11 +41,17 @@ foreach ($Viewport in $Viewports) {
Write-Host "$Viewport $Url -> $OutputPath" Write-Host "$Viewport $Url -> $OutputPath"
& pnpx --yes playwright screenshot ` & pnpx --yes playwright screenshot `
--viewport-size $Viewport ` --viewport-size $Viewport `
--wait-for-timeout 800 ` --wait-for-selector $ReadySelector `
$Url ` $Url `
$OutputPath $OutputPath
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Screenshot capture failed for $Url at $Viewport (exit code $LASTEXITCODE)" throw "Screenshot capture failed for $Url at $Viewport (exit code $LASTEXITCODE)"
} }
if (-not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) {
throw "Screenshot command reported success but did not write $OutputPath"
}
if ((Get-Item -LiteralPath $OutputPath).Length -le 0) {
throw "Screenshot command wrote an empty PNG: $OutputPath"
}
} }
} }
@@ -128,6 +128,34 @@ describe("useTimelineAgent", () => {
expect(start).toHaveBeenCalledWith("live"); 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", () => { it("does not offer an explicit live launch when health has failed", () => {
const start = vi.fn(); const start = vi.fn();
const demo = demoController({ start }); const demo = demoController({ start });
@@ -91,9 +91,15 @@ export const useTimelineAgent = (
const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough"; const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough";
const canRun = demo.canStart && !demo.inFlight && demo.state.phase !== "running"; 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" 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 runPreparedWorkflow = useCallback(async (requestedMode?: TimelineAgentMode) => {
const mode = requestedMode ?? modeLabel; const mode = requestedMode ?? modeLabel;
@@ -111,6 +111,15 @@ describe("PresentationRoute", () => {
15000, 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 = [ const visualRouteContracts = [
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", primary: "thesis opening" }, { 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" }, { hash: "#scene/lifecycle/draft", heading: "Workflow Lifecycle", primary: "workflow lifecycle rail" },
@@ -43,6 +43,29 @@ export const PresentationRoute = () => {
{ type: "jump_hash", hash: initialHash }, { 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 recording = useMemo(() => loadCanonicalDemoRecording(), []);
const replayEvidence = useMemo(() => projectRecordingToEvidence(recording), [recording]); const replayEvidence = useMemo(() => projectRecordingToEvidence(recording), [recording]);
@@ -260,7 +283,12 @@ export const PresentationRoute = () => {
); );
return ( 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> <PresentationCanvas>
<PresentationStage <PresentationStage
state={state} state={state}
@@ -1,11 +1,17 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { SceneProgress } from "./SceneProgress.js"; import { SceneProgress, shouldShowBeatCounter } from "./SceneProgress.js";
import type { MainLocation } from "./storyboard.js"; import type { MainLocation } from "./storyboard.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
describe("SceneProgress", () => { 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", () => { it("shows scene and beat position for architecture/runtime", () => {
const location: MainLocation = { const location: MainLocation = {
kind: "main", kind: "main",
@@ -1,9 +1,15 @@
import { findScene, mainScenes, type MainLocation } from "./storyboard.js"; import { findScene, mainScenes, type MainLocation } from "./storyboard.js";
import type { SceneDefinition } from "./storyboard.js";
type SceneProgressProps = { type SceneProgressProps = {
readonly location: MainLocation; readonly location: MainLocation;
}; };
export const shouldShowBeatCounter = (
scene: Pick<SceneDefinition, "alwaysShowBeatCounter"> | undefined,
totalBeats: number,
): boolean => totalBeats > 1 || scene?.alwaysShowBeatCounter === true;
export const SceneProgress = ({ location }: SceneProgressProps) => { export const SceneProgress = ({ location }: SceneProgressProps) => {
const scene = findScene(location.sceneId); const scene = findScene(location.sceneId);
const sceneIndex = mainScenes.findIndex((s) => s.id === location.sceneId); const sceneIndex = mainScenes.findIndex((s) => s.id === location.sceneId);
@@ -18,7 +24,7 @@ export const SceneProgress = ({ location }: SceneProgressProps) => {
{sceneIndex + 1} / {totalScenes} {sceneIndex + 1} / {totalScenes}
</span> </span>
)} )}
{(totalBeats > 1 || location.sceneId === "agent-handoff") && ( {shouldShowBeatCounter(scene, totalBeats) && (
<span className="scene-progress__beat"> <span className="scene-progress__beat">
{beatIndex >= 0 ? beatIndex + 1 : 1} / {totalBeats} {beatIndex >= 0 ? beatIndex + 1 : 1} / {totalBeats}
</span> </span>
@@ -1,15 +1,16 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { findBeat, findScene } from "../storyboard.js"; import { findBeat, findScene } from "../storyboard.js";
import { PreparedAuthoringLifecycleScene } from "./PreparedAuthoringLifecycleScene.js"; import { PreparedAuthoringLifecycleScene } from "./PreparedAuthoringLifecycleScene.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
const renderBeat = (beatId: string) => { const renderBeat = (beatId: string, onAdvance?: () => void) => {
const scene = findScene("prepared-lifecycle"); const scene = findScene("prepared-lifecycle");
const beat = findBeat("prepared-lifecycle", beatId); const beat = findBeat("prepared-lifecycle", beatId);
if (!scene || !beat) throw new Error(`missing 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", () => { describe("PreparedAuthoringLifecycleScene", () => {
@@ -85,6 +86,35 @@ describe("PreparedAuthoringLifecycleScene", () => {
expect(workspace.querySelectorAll('[data-visual-role="lifecycle-primary"]')).toHaveLength(1); 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([ it.each([
["discover", "Discover"], ["discover", "Discover"],
["draft", "Draft"], ["draft", "Draft"],
@@ -63,6 +63,9 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance }: Prep
onDraftChange={(draft) => dispatch({ type: "draft_edited", draft })} onDraftChange={(draft) => dispatch({ type: "draft_edited", draft })}
onSubmit={(submittedText) => { onSubmit={(submittedText) => {
dispatch({ type: "draft_edited", draft: submittedText }); dispatch({ type: "draft_edited", draft: submittedText });
if (beatId === "discover") {
dispatch({ type: "discover_submitted" });
}
if (beatId === "draft") { if (beatId === "draft") {
dispatch({ type: "draft_submitted" }); dispatch({ type: "draft_submitted" });
onAdvance?.(); 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 { Button } from "../../components/ui/button.js";
import { Textarea } from "../../components/ui/textarea.js"; import { Textarea } from "../../components/ui/textarea.js";
import { AuthoringConversation } from "./AuthoringConversation.js"; import { AuthoringConversation } from "./AuthoringConversation.js";
@@ -7,6 +7,7 @@ import type {
Scene9MessageProjection, Scene9MessageProjection,
Scene9SubmittedOverrides, Scene9SubmittedOverrides,
} from "./scene9-message-state.js"; } from "./scene9-message-state.js";
import { PREPARED_COMPOSER_HELP, usePreparedComposerSubmit } from "./usePreparedComposerSubmit.js";
export type PresentationAssistantPaneProps = { export type PresentationAssistantPaneProps = {
readonly phase: AuthoringPhaseId; readonly phase: AuthoringPhaseId;
@@ -52,16 +53,10 @@ export const PresentationAssistantPane = ({
setDraft(nextDraft); setDraft(nextDraft);
onDraftChange(nextDraft); onDraftChange(nextDraft);
}; };
const submit = (event?: FormEvent<HTMLFormElement>) => { const { submit, handleKeyDown } = usePreparedComposerSubmit(
event?.preventDefault(); canSubmit,
if (canSubmit) onSubmit(draft); () => onSubmit(draft),
}; );
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
};
return ( return (
<aside <aside
@@ -104,7 +99,7 @@ export const PresentationAssistantPane = ({
</Button> </Button>
</div> </div>
<p id="scene9-authoring-message-help" className="presentation-assistant-pane__composer-help"> <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> </p>
{runRequested !== null ? ( {runRequested !== null ? (
<p role="status" className="presentation-assistant-pane__run-status"> <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 { Button } from "../../components/ui/button.js";
import { Textarea } from "../../components/ui/textarea.js"; import { Textarea } from "../../components/ui/textarea.js";
import { AuthoringConversation } from "./AuthoringConversation.js"; import { AuthoringConversation } from "./AuthoringConversation.js";
@@ -7,6 +6,7 @@ import {
type Scene8EntryAction, type Scene8EntryAction,
type Scene8EntryState, type Scene8EntryState,
} from "./scene8-entry-state.js"; } from "./scene8-entry-state.js";
import { PREPARED_COMPOSER_HELP, usePreparedComposerSubmit } from "./usePreparedComposerSubmit.js";
type Scene8ChatEntryProps = { type Scene8ChatEntryProps = {
readonly state: Scene8EntryState; readonly state: Scene8EntryState;
@@ -17,11 +17,10 @@ type Scene8ChatEntryProps = {
export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => { export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
const submitted = state.phase === "submitted"; const submitted = state.phase === "submitted";
const canSubmit = canSubmitScene8Entry(state); const canSubmit = canSubmitScene8Entry(state);
const { submit, handleKeyDown } = usePreparedComposerSubmit(
const submit = (event?: FormEvent<HTMLFormElement>) => { canSubmit,
event?.preventDefault(); () => dispatch({ type: "submit" }),
if (canSubmit) dispatch({ type: "submit" }); );
};
return ( return (
<section <section
@@ -43,12 +42,7 @@ export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
value={state.draft} value={state.draft}
disabled={submitted} disabled={submitted}
onChange={(event) => dispatch({ type: "draft_changed", draft: event.target.value })} onChange={(event) => dispatch({ type: "draft_changed", draft: event.target.value })}
onKeyDown={(event) => { onKeyDown={handleKeyDown}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
aria-describedby="scene8-authoring-request-help" aria-describedby="scene8-authoring-request-help"
/> />
<div className="agent-handoff-scene__composer-actions"> <div className="agent-handoff-scene__composer-actions">
@@ -57,7 +51,7 @@ export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
</Button> </Button>
</div> </div>
<p id="scene8-authoring-request-help" className="agent-handoff-scene__composer-help"> <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> </p>
</form> </form>
{submitted ? ( {submitted ? (
@@ -205,4 +205,20 @@ describe("projectPreparedAuthoringThread", () => {
canonical.filter((message) => message.id.endsWith("-tools")), 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 = ( export const projectPreparedAuthoringThread = (
throughPhase: AuthoringPhaseId = "deployment", throughPhase: AuthoringPhaseId = "deployment",
requestOverride?: string, requestOverride?: string,
requestOverrides?: Readonly<Partial<Record<"validate" | "deployment", string>>>, requestOverrides?: Readonly<Partial<Record<"discover" | "validate" | "deployment", string>>>,
): readonly AgentMessage[] => { ): readonly AgentMessage[] => {
const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase); const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase);
if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`); if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`);
let requestReplaced = false; let requestReplaced = false;
const phaseOverrideApplied = new Set<AuthoringPhaseId>();
return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => { return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => {
const phaseOverride = const phaseOverride =
phase.phase === "validate" phase.phase === "discover"
? requestOverrides?.discover
: phase.phase === "validate"
? requestOverrides?.validate ? requestOverrides?.validate
: phase.phase === "deployment" : phase.phase === "deployment"
? requestOverrides?.deployment ? requestOverrides?.deployment
: undefined; : undefined;
const conversation = phase.conversation.map((turn, index) => { const conversation = phase.conversation.map((turn, index) => {
const shouldReplaceRequest = let replacement: string | undefined;
turn.role === "user" && if (turn.role === "user" && phaseOverride !== undefined && !phaseOverrideApplied.has(phase.phase)) {
(phaseOverride !== undefined || (requestOverride !== undefined && !requestReplaced)); replacement = phaseOverride;
if (shouldReplaceRequest && phaseOverride === undefined) requestReplaced = true; phaseOverrideApplied.add(phase.phase);
} else if (
turn.role === "user"
&& phaseOverride === undefined
&& requestOverride !== undefined
&& !requestReplaced
) {
replacement = requestOverride;
requestReplaced = true;
}
return agentTextMessage( return agentTextMessage(
`authoring-${phase.phase}-message-${index}`, `authoring-${phase.phase}-message-${index}`,
turn.role, turn.role,
shouldReplaceRequest ? (phaseOverride ?? requestOverride ?? turn.text) : turn.text, replacement ?? turn.text,
); );
}); });
const groupId = authoringToolGroupId(phase.phase); 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", () => { it("ignores blank submits and keeps duplicate submits idempotent", () => {
const blank = { ...initialScene9MessageState, draft: " \n\t" }; const blank = { ...initialScene9MessageState, draft: " \n\t" };
expect(scene9MessageReducer(blank, { type: "draft_submitted" })).toBe(blank); expect(scene9MessageReducer(blank, { type: "draft_submitted" })).toBe(blank);
@@ -1,7 +1,7 @@
import type { AuthoringPhaseId } from "./authoring-recording.js"; import type { AuthoringPhaseId } from "./authoring-recording.js";
export type Scene9MessagePhase = AuthoringPhaseId; export type Scene9MessagePhase = AuthoringPhaseId;
export type Scene9DestinationPhase = "validate" | "deployment"; export type Scene9DestinationPhase = "discover" | "validate" | "deployment";
export const SCENE9_PHASE_PROMPTS: Readonly<Record<Scene9MessagePhase, string>> = { export const SCENE9_PHASE_PROMPTS: Readonly<Record<Scene9MessagePhase, string>> = {
discover: "", discover: "",
@@ -38,6 +38,7 @@ export const initialScene9MessageState: Scene9MessageState = {
export type Scene9MessageAction = export type Scene9MessageAction =
| { readonly type: "draft_edited"; readonly draft: string } | { readonly type: "draft_edited"; readonly draft: string }
| { readonly type: "discover_submitted" }
| { readonly type: "draft_submitted" } | { readonly type: "draft_submitted" }
| { readonly type: "artifact_submitted" } | { readonly type: "artifact_submitted" }
| { readonly type: "run_requested" }; | { readonly type: "run_requested" };
@@ -66,6 +67,8 @@ export const scene9MessageReducer = (
switch (action.type) { switch (action.type) {
case "draft_edited": case "draft_edited":
return state.runRequested === null ? { ...state, draft: action.draft } : state; return state.runRequested === null ? { ...state, draft: action.draft } : state;
case "discover_submitted":
return submitOverride(state, "discover");
case "draft_submitted": case "draft_submitted":
return submitOverride(state, "validate"); return submitOverride(state, "validate");
case "artifact_submitted": case "artifact_submitted":
@@ -92,7 +95,7 @@ export const projectScene9Message = (
placeholder: SCENE9_PHASE_PLACEHOLDERS[phase], 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 = ( export const projectScene9SubmittedOverrides = (
state: Scene9MessageState, state: Scene9MessageState,
): Scene9SubmittedOverrides => state.submittedOverrides; ): 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 () => { it("can keep a first-stage group anchored at the start of the transcript", async () => {
const setScrollTop = vi.fn(); 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> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "assistant-start-scroll-text", id: "assistant-start-scroll-text",
@@ -192,10 +198,14 @@ describe("AssistantOperatorThread", () => {
]; ];
try { try {
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", { Object.defineProperties(HTMLDivElement.prototype, {
configurable: true, scrollTop: { configurable: true, get: () => 0, set: setScrollTop },
get: () => 0, scrollHeight: { configurable: true, get: () => 400 },
set: setScrollTop, clientHeight: { configurable: true, get: () => 80 },
});
Object.defineProperties(HTMLElement.prototype, {
offsetTop: { configurable: true, get: () => 120 },
offsetHeight: { configurable: true, get: () => 40 },
}); });
render( render(
<AssistantOperatorThread <AssistantOperatorThread
@@ -209,10 +219,15 @@ describe("AssistantOperatorThread", () => {
await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0)); await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0));
} finally { } finally {
if (originalDescriptor) { for (const [name, descriptor] of Object.entries(descriptors)) {
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", originalDescriptor); const prototype = name === "offsetTop" || name === "offsetHeight"
? HTMLElement.prototype
: HTMLDivElement.prototype;
if (descriptor) {
Object.defineProperty(prototype, name, descriptor);
} else { } else {
delete (HTMLDivElement.prototype as { scrollTop?: number }).scrollTop; 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 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 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", () => { describe("presentation.css", () => {
it("keeps the demo footer rail compact and removes the old launch control", () => { 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; 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", () => { 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.match(/\.presentation-stage\[data-scene-view="agent"\] \.presentation-stage__primary\s*\{/g)).toHaveLength(2);
expect(css).toMatch( expect(cssBlocks(css, ".presentation-stage__primary > .agent-handoff-scene")
/\.presentation-stage__primary > \.agent-handoff-scene\s*\{[\s\S]*?margin-inline:\s*auto;/, .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(conversation).toContain("flex: 1 1 auto;");
expect(css).toMatch(/\.agent-handoff-scene__composer\s*\{[\s\S]*?width:\s*min\(calc\(100% - 3rem\), 72rem\);/); expect(conversation).toContain("min-height: 0;");
expect(css).toMatch( expect(conversation).toContain("overflow: auto;");
/\.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;/, const compactAgentPrimary = cssBlock(
cssBlock(css, "@media (max-width: 1100px)") ?? "",
'.presentation-stage[data-scene-view="agent"] .presentation-stage__primary',
); );
expect(css).toMatch( expect(compactAgentPrimary).toContain("align-items: center;");
/@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("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; 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 { .evaluation-board[data-evaluation-focus="cohort"] .evaluation-board__cohort {
border-right: 0; border-right: 0;
padding-block: 1.35rem 0.8rem; padding-block: 1.35rem 0.8rem;
@@ -40,6 +40,7 @@ export type SceneDefinition = {
readonly evidencePointer: string; readonly evidencePointer: string;
readonly view: SceneView; readonly view: SceneView;
readonly beats: readonly SceneBeatDefinition[]; readonly beats: readonly SceneBeatDefinition[];
readonly alwaysShowBeatCounter?: boolean;
}; };
const defineScenes = <const Scenes extends readonly SceneDefinition[]>(scenes: Scenes): Scenes => scenes; const defineScenes = <const Scenes extends readonly SceneDefinition[]>(scenes: Scenes): Scenes => scenes;
@@ -158,6 +159,7 @@ export const mainScenes = defineScenes([
claimClass: "implemented", claimClass: "implemented",
evidencePointer: "Constrained demo agent and prepared replay recipe", evidencePointer: "Constrained demo agent and prepared replay recipe",
view: "agent", view: "agent",
alwaysShowBeatCounter: true,
beats: [ beats: [
sceneBeat("request", "Operator request", "A thin agent interface receives the report request.", { chatMode: "hidden", chatTheme: "light" }), sceneBeat("request", "Operator request", "A thin agent interface receives the report request.", { chatMode: "hidden", chatTheme: "light" }),
], ],