refactor: make scene 8 a single chat entry beat

This commit is contained in:
lda
2026-07-12 06:15:19 +07:00 Verified
parent f22077da37
commit c5f9bed26d
12 changed files with 42 additions and 120 deletions
@@ -22,12 +22,12 @@
## Task 1: Remove The Redundant Scene 8 Handoff Beat
- [ ] Write failing storyboard/navigation and Scene 8 tests before changing the catalog. Cover the single-beat count, valid request hash, invalid/removed handoff hash fallback, and footer progress.
- [ ] Modify `web/apps/console/src/presentation/storyboard.ts` so `agent-handoff` contains only the `request` beat. Update its caption/title wording if needed so it describes the request-to-first-turn interaction rather than a separate durable-work handoff.
- [ ] Simplify `AgentHandoffScene.tsx` and its tests: remove the `handoff` branch, deployment projection, and any comments claiming Scene 8 renders the full authoring transcript. Keep the local empty/submitted reducer and `Scene8ChatEntry` behavior.
- [ ] Remove stale `/handoff` references from `PresentationRoute.test.tsx`, `SceneBody.test.tsx`, `web/README.md`, and nearby docs. Do not add a compatibility alias for a route that has no real persisted/external contract; invalid hashes should use the existing fail-closed default.
- [ ] Confirm the full authoring conversation remains available through Scene 9 and is not deleted with the route cleanup.
- [ ] Run focused storyboard, route, and Scene 8 tests plus typecheck. Commit as `refactor: make scene 8 a single chat entry beat`.
- [x] Write failing storyboard/navigation and Scene 8 tests before changing the catalog. Cover the single-beat count, valid request hash, invalid/removed handoff hash fallback, and footer progress.
- [x] Modify `web/apps/console/src/presentation/storyboard.ts` so `agent-handoff` contains only the `request` beat. Update its caption/title wording if needed so it describes the request-to-first-turn interaction rather than a separate durable-work handoff.
- [x] Simplify `AgentHandoffScene.tsx` and its tests: remove the `handoff` branch, deployment projection, and any comments claiming Scene 8 renders the full authoring transcript. Keep the local empty/submitted reducer and `Scene8ChatEntry` behavior.
- [x] Remove stale `/handoff` references from `PresentationRoute.test.tsx`, `SceneBody.test.tsx`, `web/README.md`, and nearby docs. Do not add a compatibility alias for a route that has no real persisted/external contract; invalid hashes should use the existing fail-closed default.
- [x] Confirm the full authoring conversation remains available through Scene 9 and is not deleted with the route cleanup.
- [x] Run focused storyboard, route, and Scene 8 tests plus typecheck. Commit as `refactor: make scene 8 a single chat entry beat`.
## Task 2: Vendor And Validate The Assistant Thread Surface
@@ -97,8 +97,8 @@ When Send is activated with non-empty text:
The send action must not call `workflow.runs.start`, `workflow.runs.resume`, or
any other workflow RPC. It is a scripted presentation entry event.
If the presenter advances with `ArrowRight`, the existing storyboard location
changes to `#scene/agent-handoff/handoff`, and the full prepared authoring
Scene 8 has no second handoff beat. Advancing with `ArrowRight` moves directly
to Scene 9's first lifecycle beat, where the full prepared authoring
conversation becomes visible through the existing phase projection.
If the composer is empty or contains only whitespace, Send is disabled and no
@@ -176,8 +176,6 @@ encoded in the URL hash.
- Whitespace-only draft: treated as blank.
- Send while already submitted: disabled or ignored; it must not duplicate the
prepared conversation.
- Direct navigation to the handoff beat: render the full prepared conversation
without requiring the request-beat send transition.
- Missing recording data: preserve the existing bounded presentation fallback
and render an explicit unavailable state rather than inventing a live result.
@@ -191,13 +189,11 @@ Add or update tests for:
3. sending reveals the prepared request/assistant/Discover content and does not
call workflow RPC operations;
4. sending twice does not duplicate the first conversation;
5. direct navigation to the handoff beat still renders the full prepared
conversation;
6. Scene 8 no longer renders the standalone `Run prepared workflow` button or
5. Scene 8 no longer renders the standalone `Run prepared workflow` button or
the old phase rail;
7. 1280x720 and 1024x768 browser smoke captures show the composer without
6. 1280x720 and 1024x768 browser smoke captures show the composer without
clipping or horizontal overflow;
8. reduced-motion mode does not hide the submitted conversation.
7. reduced-motion mode does not hide the submitted conversation.
## Out Of Scope
@@ -214,6 +210,6 @@ Add or update tests for:
The slice is complete when the presenter can open
`#scene/agent-handoff/request`, see a credible full-screen chat entry, press
Send, see the first prepared authoring turn, advance to the handoff beat, and
continue into Scene 9 without a standalone run button or a second chat state
runtime. The behavior is covered by focused tests and 16:9/4:3 browser smoke.
Send, see the first prepared authoring turn, and continue directly into Scene 9
without a standalone run button or a second chat state runtime. The behavior is
covered by focused tests and 16:9/4:3 browser smoke.
+10 -12
View File
@@ -180,12 +180,12 @@ claim boundaries and future work explicit, and end on the canonical defense
discussion index rather than a benchmark or generic conclusion.
Scenes 8 and 9 use the canonical prepared-authoring recording as their only
execution evidence. Scene 8 is a full-screen chat entry: its prefilled request
is submitted locally, then reveals the first deterministic user, assistant, and
Discover tool group. It is deterministic replay, not a live LLM chat, and does
not start a workflow run. The handoff beat reveals the full prepared conversation;
Scene 9 breaks the prepared authoring into five phases
with the same conversation contracted into a synchronized bottom dock. Each
execution evidence. Scene 8 is a single full-screen chat-entry beat: its
prefilled request is submitted locally, then reveals the first deterministic
user, assistant, and Discover tool group. It is deterministic replay, not a live
LLM chat, and does not start a workflow run. Scene 9 breaks the prepared
authoring into five phases with the same conversation contracted into a
synchronized bottom dock. Each
phase opens its matching prepared tool group beneath a factual source, graph,
repair, artifact, or deployment view. Neither scene calls workflow authoring
RPC operations — they consume deterministic prepared data. Scenes 10 through
@@ -203,8 +203,7 @@ presentation timeline.
The key deep-link-addressable defense states include:
- `/present#scene/agent-handoff/request` — Scene 8, prepared handoff request
- `/present#scene/agent-handoff/handoff` — Scene 8, agent accepts
- `/present#scene/agent-handoff/request` — Scene 8, prepared authoring request
- `/present#scene/prepared-lifecycle/discover` — Scene 9, discover phase
- `/present#scene/prepared-lifecycle/draft` — Scene 9, draft phase
- `/present#scene/prepared-lifecycle/deployment` — Scene 9, deployment phase
@@ -333,11 +332,10 @@ Scenes 8 and 9 are the prepared authoring story. They use deterministic data
from the committed `projectPreparedAuthoring()` recording and never call
workflow authoring RPC operations.
- **Scene 8 (Agent Handoff)**: a full-screen deterministic chat entry that
- **Scene 8 (Agent Request)**: a single full-screen deterministic chat entry that
pre-fills the report-authoring request. Send is local presentation state and
reveals the first prepared Discover tool group; the `handoff` beat reveals
the full completed conversation with prepared `wf` tool groups across all
authoring phases. This is not a live LLM chat or workflow run.
reveals the first prepared Discover tool group. This is not a live LLM chat
or workflow run.
- **Scene 9 (Prepared Workflow Lifecycle)**: a five-phase lifecycle
(discover, draft, validate, artifact, deployment) with a compact phase rail
and one dominant factual product projection per beat. The Scene 8 thread
@@ -86,7 +86,7 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />);
expect(screen.getByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByRole("button", { name: /deployment.*2 tool calls/i })).toBeInTheDocument();
expect(await screen.findByLabelText("authoring phase rail")).toBeInTheDocument();
});
it("renders audience progress chrome without rail or mode label", async () => {
@@ -467,15 +467,6 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("button", { name: /discover.*4 tool calls/i })).toBeInTheDocument();
});
it("navigates to Scene 8 handoff beat via hash", async () => {
window.location.hash = "#scene/agent-handoff/handoff";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i })).toBeInTheDocument();
});
it.each([
"#scene/prepared-lifecycle/discover",
"#scene/prepared-lifecycle/draft",
@@ -439,11 +439,11 @@ describe("SceneBody", () => {
expect(screen.getAllByText(/lda_report_case_study/i).length).toBeGreaterThanOrEqual(1);
});
it("routes Scene 8 through the prepared authoring conversation", () => {
renderSceneBodyAtMainLocation("agent-handoff", "handoff");
it("renders Scene 8 as the request entry beat", () => {
renderSceneBodyAtMainLocation("agent-handoff", "request");
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /deployment.*2 tool calls/i })).not.toBeInTheDocument();
});
it("renders evidence before discussion links so the chip lane cannot cover evidence text", () => {
@@ -5,10 +5,10 @@ import { findBeat, findScene } from "../storyboard.js";
import { AgentHandoffScene } from "./AgentHandoffScene.js";
import { SCENE8_REQUEST } from "./scene8-entry-state.js";
const renderBeat = (beatId: "request" | "handoff") => {
const renderRequestBeat = () => {
const scene = findScene("agent-handoff");
const beat = findBeat("agent-handoff", beatId);
if (!scene || !beat) throw new Error(`missing agent-handoff/${beatId}`);
const beat = findBeat("agent-handoff", "request");
if (!scene || !beat) throw new Error("missing agent-handoff/request");
return render(<AgentHandoffScene scene={scene} beat={beat} />);
};
@@ -23,11 +23,11 @@ describe("AgentHandoffScene", () => {
/>,
);
expect(screen.getByRole("region", { name: "prepared agent handoff" })).toHaveAttribute(
expect(screen.getByRole("region", { name: "prepared agent request" })).toHaveAttribute(
"data-handoff-phase",
"discover",
);
expect(screen.getByRole("region", { name: "prepared agent handoff" })).toHaveAttribute(
expect(screen.getByRole("region", { name: "prepared agent request" })).toHaveAttribute(
"data-presentation-surface",
"editorial",
);
@@ -37,46 +37,17 @@ describe("AgentHandoffScene", () => {
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
});
it("advances the same conversation to deployment evidence", () => {
render(
<AgentHandoffScene
scene={findScene("agent-handoff")!}
beat={findBeat("agent-handoff", "handoff")!}
/>,
);
expect(screen.getByRole("region", { name: "prepared agent handoff" })).toHaveAttribute(
"data-handoff-phase",
"deployment",
);
expect(screen.getAllByText(/workflow\.deployments\.save/i).length).toBeGreaterThan(0);
});
it("reveals separated user and assistant turns after local submission", async () => {
renderBeat("request");
renderRequestBeat();
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getAllByText(/report|workflow|prepare/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/inspect|capabilities|sources|schemas|let me/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByRole("button", { name: /discover.*4 tool calls/i })).toBeInTheDocument();
});
it("interleaves prepared workflow tool groups with the handoff conversation", () => {
renderBeat("handoff");
expect(screen.getAllByText(/workflow\.deployments\.save/i).length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i }))
.toHaveAttribute("aria-expanded", "true");
});
it("does not render prepared workflow lifecycle content", () => {
renderBeat("request");
renderRequestBeat();
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
cleanup();
renderBeat("handoff");
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
});
it("does not expose a workflow run action", () => {
renderBeat("handoff");
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
});
});
@@ -1,5 +1,4 @@
import { useReducer } from "react";
import { AuthoringConversation } from "./AuthoringConversation.js";
import { Scene8ChatEntry } from "./Scene8ChatEntry.js";
import {
initialScene8EntryState,
@@ -12,32 +11,17 @@ type AgentHandoffSceneProps = {
readonly beat: SceneBeatDefinition;
};
/**
* Scene 8 request and handoff beats share one local deterministic entry state.
*/
export const AgentHandoffScene = ({ beat }: AgentHandoffSceneProps) => {
const [entryState, dispatch] = useReducer(scene8EntryReducer, initialScene8EntryState);
const phase = beat.id === "handoff" ? "deployment" : "discover";
return (
<section
className="agent-handoff-scene"
aria-label="prepared agent handoff"
data-handoff-phase={phase}
aria-label="prepared agent request"
data-handoff-phase="discover"
data-presentation-surface="editorial"
>
{beat.id === "request" ? (
<Scene8ChatEntry state={entryState} dispatch={dispatch} />
) : (
<div className="agent-handoff-scene__handoff">
<AuthoringConversation
throughPhase="deployment"
activePhase="deployment"
surface="stage"
{...(entryState.phase === "submitted" ? { requestOverride: entryState.request } : {})}
/>
</div>
)}
{beat.id === "request" && <Scene8ChatEntry state={entryState} dispatch={dispatch} />}
</section>
);
};
@@ -157,7 +157,6 @@ const beatContracts = {
"authoring/diagnose": { mode: "evidence", primarySurface: "authoring-diagnostic", supportSurface: "authoring-loop" },
"authoring/repair": { mode: "evidence", primarySurface: "authoring-repair", supportSurface: "authoring-loop" },
"agent-handoff/request": { mode: "conversation", primarySurface: "prepared-conversation", supportSurface: "none" },
"agent-handoff/handoff": { mode: "conversation", primarySurface: "prepared-conversation", supportSurface: "none" },
"evaluation/cohort": { mode: "evidence", primarySurface: "evaluation-cohort", supportSurface: "none" },
"evaluation/validity": { mode: "evidence", primarySurface: "evaluation-validity", supportSurface: "audit-reconciliation" },
"evaluation/findings": { mode: "evidence", primarySurface: "evaluation-findings", supportSurface: "validity-boundary" },
@@ -3006,22 +3006,6 @@
color: var(--authoring-ink, var(--text-primary));
}
.agent-handoff-scene__handoff {
min-height: 0;
height: 100%;
overflow: hidden;
}
.agent-handoff-scene__handoff .assistant-operator-thread {
width: 100%;
height: 100%;
max-height: none;
}
.agent-handoff-scene__handoff .assistant-thread__viewport {
padding: 1rem 1.2rem;
}
.agent-handoff-scene__composer {
display: grid;
gap: 0.75rem;
@@ -35,6 +35,7 @@ describe("storyboard navigation", () => {
it("falls back for unknown scene, beat, and branch hashes", () => {
expect(locationFromHash("#scene/missing/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#scene/lifecycle/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#scene/agent-handoff/handoff")).toEqual(defaultMainLocation);
expect(locationFromHash("#discuss/nope")).toEqual(defaultMainLocation);
});
@@ -43,6 +43,7 @@ describe("defense storyboard catalog", () => {
});
it("uses one editorial canvas theme and independent chat composition", () => {
expect(findScene("agent-handoff")?.beats).toHaveLength(1);
expect(findBeat("agent-handoff", "request")?.chatMode).toBe("hidden");
expect(findBeat("resume-output-evidence", "trace")?.chatMode).toBe("hidden");
});
@@ -154,15 +154,12 @@ export const mainScenes = defineScenes([
{
id: "agent-handoff",
number: 8,
title: "Agent Handoff",
title: "Agent Request",
claimClass: "implemented",
evidencePointer: "Constrained demo agent and prepared replay recipe",
view: "agent",
beats: [
// Scene 8 renders its own full-screen prepared transcript in the primary
// region, so the persistent stage chat rail must stay out of the way.
sceneBeat("request", "Operator request", "A thin agent interface receives the report request.", { chatMode: "hidden", chatTheme: "light" }),
sceneBeat("handoff", "Prepared operation", "The interface delegates durable work to lda.chat.", { chatMode: "hidden", chatTheme: "light" }),
],
},
{