fix: address presentation review findings
This commit is contained in:
+14
-11
@@ -204,8 +204,8 @@ Presentation visual audit, July 11:
|
||||
|
||||
## Completed: Presentation Recomposition And Authoring Story
|
||||
|
||||
Scenes 8 and 9 are now implemented as authentic external-agent request and
|
||||
factual prepared-authoring proof. Scene 8 is one local request-to-first-turn
|
||||
Scenes 8 and 9 now present an external-agent request through a scripted
|
||||
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
|
||||
prepared assistant pane beside five factual phase visuals in an adaptive
|
||||
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).
|
||||
Implementation:
|
||||
[`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
|
||||
authentic external-agent handoff and factual prepared-authoring proof.
|
||||
6. Completed: Presentation agent authoring story rebuilds Scenes 8 and 9 as a
|
||||
scripted surface representing an external-agent handoff and factual
|
||||
prepared-authoring proof.
|
||||
Implementation:
|
||||
[`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
|
||||
@@ -261,7 +262,7 @@ Recommended next visual slices:
|
||||
Implementation:
|
||||
[`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
|
||||
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.
|
||||
Implementation:
|
||||
[`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
|
||||
execution or RPC. Implementation:
|
||||
[`Scene 9 staged message box`](historical/superpowers/plans/2026-07-12-scene-9-staged-message-box.md).
|
||||
The boundary is explicit: Scenes 10–12 own run activation, approval,
|
||||
resume, output, and trace evidence.
|
||||
3. **Completed: Live/replay truth and run activation:** Scene 10 exposes the
|
||||
prepared-run action outside the hidden chat, retries the existing health
|
||||
probe, starts live execution explicitly, and keeps direct links and failed
|
||||
services on truthful replay evidence. Implementation:
|
||||
The boundary is explicit: the compact demo footer may start the prepared run
|
||||
from Scenes 8–12, while Scenes 10–12 own run, decision, resume, output, and
|
||||
trace evidence.
|
||||
3. **Completed: Live/replay truth and run activation:** the compact footer rail
|
||||
across Scenes 8–12 exposes the prepared-run action, retries the existing
|
||||
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).
|
||||
4. **Completed: Scene 11 compression:** reduce the typed-human-boundary scene to two
|
||||
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/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/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` |
|
||||
| `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` |
|
||||
|
||||
@@ -51,7 +51,7 @@ pnpm --filter @lda/console build
|
||||
3. 1280x720 uses a 1280x720 logical canvas
|
||||
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
|
||||
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
|
||||
8. Runtime & providers expands in place with breadcrumb
|
||||
9. Configured providers expands second level
|
||||
|
||||
@@ -26,9 +26,10 @@ when `wf-rpc-server` is running.
|
||||
|
||||
### Launch surface
|
||||
|
||||
Expose a compact prepared-run control on Scene 10's `operation` beat. It is a
|
||||
scene-owned control, not a reintroduced chat rail. It must remain visible while
|
||||
the target is checking, ready, failed, or the live run is active.
|
||||
Expose one compact prepared-run control in `PresentationFooter` throughout
|
||||
Scenes 8-12. It is demo chrome, not a reintroduced chat rail or a scene-owned
|
||||
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:
|
||||
|
||||
@@ -83,8 +84,8 @@ discussion views should not gain a persistent live-service badge or run action.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. With `wf-rpc-server` and the web server running, Scene 10's operation beat
|
||||
visibly exposes `Run prepared workflow` and starts a live timeline.
|
||||
1. With `wf-rpc-server` and the web server running, the compact footer rail on
|
||||
Scenes 8-12 exposes `Run prepared workflow` and starts a live timeline.
|
||||
2. A live run records deployment inspection, run start, interrupt, resume, and
|
||||
trace evidence using the existing `callOperation` path.
|
||||
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.
|
||||
7. The launch control cannot issue duplicate starts while an operation is in
|
||||
flight or a live run is already active.
|
||||
8. Scene 8 and Scene 9 remain deterministic authoring replay and do not call
|
||||
workflow authoring RPC operations.
|
||||
8. Scene 8 chat Send and Scene 9 staged messages remain deterministic authoring
|
||||
replay and do not call workflow authoring RPC operations. Their separate
|
||||
footer control may start the prepared run.
|
||||
|
||||
## 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
|
||||
conversation becomes a secondary modal.
|
||||
|
||||
The standalone `Run prepared workflow` action is removed from Scene 8. The
|
||||
workflow-run action belongs to Slice 3 and will be exposed in Scene 10.
|
||||
The standalone in-scene `Run prepared workflow` action is removed from Scene 8.
|
||||
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
|
||||
|
||||
@@ -77,7 +79,8 @@ On the Scene 8 request beat, render:
|
||||
- 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?`;
|
||||
- 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
|
||||
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
|
||||
source for all revealed turns.
|
||||
|
||||
Scene 8 must work with no workflow server and must not change the live/replay
|
||||
target badge. The target badge remains scoped by the later demo truth slice.
|
||||
Scene 8 chat must work with no workflow server and must not change live/replay
|
||||
state. The later demo truth slice owns the separate footer badge and launch
|
||||
control across the demo arc.
|
||||
|
||||
### Responsive Behavior
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
param(
|
||||
[string]$BaseUrl = "http://127.0.0.1:5173",
|
||||
[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"
|
||||
@@ -40,11 +41,17 @@ foreach ($Viewport in $Viewports) {
|
||||
Write-Host "$Viewport $Url -> $OutputPath"
|
||||
& pnpx --yes playwright screenshot `
|
||||
--viewport-size $Viewport `
|
||||
--wait-for-timeout 800 `
|
||||
--wait-for-selector $ReadySelector `
|
||||
$Url `
|
||||
$OutputPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
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");
|
||||
});
|
||||
|
||||
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