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 ## 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. - [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.
- [ ] 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] 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. - [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.
- [ ] 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] 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. - [x] 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] 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 ## 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 The send action must not call `workflow.runs.start`, `workflow.runs.resume`, or
any other workflow RPC. It is a scripted presentation entry event. any other workflow RPC. It is a scripted presentation entry event.
If the presenter advances with `ArrowRight`, the existing storyboard location Scene 8 has no second handoff beat. Advancing with `ArrowRight` moves directly
changes to `#scene/agent-handoff/handoff`, and the full prepared authoring to Scene 9's first lifecycle beat, where the full prepared authoring
conversation becomes visible through the existing phase projection. conversation becomes visible through the existing phase projection.
If the composer is empty or contains only whitespace, Send is disabled and no 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. - Whitespace-only draft: treated as blank.
- Send while already submitted: disabled or ignored; it must not duplicate the - Send while already submitted: disabled or ignored; it must not duplicate the
prepared conversation. 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 - Missing recording data: preserve the existing bounded presentation fallback
and render an explicit unavailable state rather than inventing a live result. 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 3. sending reveals the prepared request/assistant/Discover content and does not
call workflow RPC operations; call workflow RPC operations;
4. sending twice does not duplicate the first conversation; 4. sending twice does not duplicate the first conversation;
5. direct navigation to the handoff beat still renders the full prepared 5. Scene 8 no longer renders the standalone `Run prepared workflow` button or
conversation;
6. Scene 8 no longer renders the standalone `Run prepared workflow` button or
the old phase rail; 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; 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 ## Out Of Scope
@@ -214,6 +210,6 @@ Add or update tests for:
The slice is complete when the presenter can open The slice is complete when the presenter can open
`#scene/agent-handoff/request`, see a credible full-screen chat entry, press `#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 Send, see the first prepared authoring turn, and continue directly into Scene 9
continue into Scene 9 without a standalone run button or a second chat state without a standalone run button or a second chat state runtime. The behavior is
runtime. The behavior is covered by focused tests and 16:9/4:3 browser smoke. 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. discussion index rather than a benchmark or generic conclusion.
Scenes 8 and 9 use the canonical prepared-authoring recording as their only 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 execution evidence. Scene 8 is a single full-screen chat-entry beat: its
is submitted locally, then reveals the first deterministic user, assistant, and prefilled request is submitted locally, then reveals the first deterministic
Discover tool group. It is deterministic replay, not a live LLM chat, and does user, assistant, and Discover tool group. It is deterministic replay, not a live
not start a workflow run. The handoff beat reveals the full prepared conversation; LLM chat, and does not start a workflow run. Scene 9 breaks the prepared
Scene 9 breaks the prepared authoring into five phases authoring into five phases with the same conversation contracted into a
with the same conversation contracted into a synchronized bottom dock. Each synchronized bottom dock. Each
phase opens its matching prepared tool group beneath a factual source, graph, phase opens its matching prepared tool group beneath a factual source, graph,
repair, artifact, or deployment view. Neither scene calls workflow authoring repair, artifact, or deployment view. Neither scene calls workflow authoring
RPC operations — they consume deterministic prepared data. Scenes 10 through RPC operations — they consume deterministic prepared data. Scenes 10 through
@@ -203,8 +203,7 @@ presentation timeline.
The key deep-link-addressable defense states include: The key deep-link-addressable defense states include:
- `/present#scene/agent-handoff/request` — Scene 8, prepared handoff request - `/present#scene/agent-handoff/request` — Scene 8, prepared authoring request
- `/present#scene/agent-handoff/handoff` — Scene 8, agent accepts
- `/present#scene/prepared-lifecycle/discover` — Scene 9, discover phase - `/present#scene/prepared-lifecycle/discover` — Scene 9, discover phase
- `/present#scene/prepared-lifecycle/draft` — Scene 9, draft phase - `/present#scene/prepared-lifecycle/draft` — Scene 9, draft phase
- `/present#scene/prepared-lifecycle/deployment` — Scene 9, deployment 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 from the committed `projectPreparedAuthoring()` recording and never call
workflow authoring RPC operations. 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 pre-fills the report-authoring request. Send is local presentation state and
reveals the first prepared Discover tool group; the `handoff` beat reveals reveals the first prepared Discover tool group. This is not a live LLM chat
the full completed conversation with prepared `wf` tool groups across all or workflow run.
authoring phases. This is not a live LLM chat or workflow run.
- **Scene 9 (Prepared Workflow Lifecycle)**: a five-phase lifecycle - **Scene 9 (Prepared Workflow Lifecycle)**: a five-phase lifecycle
(discover, draft, validate, artifact, deployment) with a compact phase rail (discover, draft, validate, artifact, deployment) with a compact phase rail
and one dominant factual product projection per beat. The Scene 8 thread and one dominant factual product projection per beat. The Scene 8 thread
@@ -86,7 +86,7 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />); render(<PresentationRoute />);
expect(screen.getByRole("textbox", { name: /authoring request/i })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
await userEvent.keyboard("{ArrowRight}"); 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 () => { 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(); 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([ it.each([
"#scene/prepared-lifecycle/discover", "#scene/prepared-lifecycle/discover",
"#scene/prepared-lifecycle/draft", "#scene/prepared-lifecycle/draft",
@@ -439,11 +439,11 @@ describe("SceneBody", () => {
expect(screen.getAllByText(/lda_report_case_study/i).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText(/lda_report_case_study/i).length).toBeGreaterThanOrEqual(1);
}); });
it("routes Scene 8 through the prepared authoring conversation", () => { it("renders Scene 8 as the request entry beat", () => {
renderSceneBodyAtMainLocation("agent-handoff", "handoff"); renderSceneBodyAtMainLocation("agent-handoff", "request");
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/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", () => { 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 { AgentHandoffScene } from "./AgentHandoffScene.js";
import { SCENE8_REQUEST } from "./scene8-entry-state.js"; import { SCENE8_REQUEST } from "./scene8-entry-state.js";
const renderBeat = (beatId: "request" | "handoff") => { const renderRequestBeat = () => {
const scene = findScene("agent-handoff"); const scene = findScene("agent-handoff");
const beat = findBeat("agent-handoff", beatId); const beat = findBeat("agent-handoff", "request");
if (!scene || !beat) throw new Error(`missing agent-handoff/${beatId}`); if (!scene || !beat) throw new Error("missing agent-handoff/request");
return render(<AgentHandoffScene scene={scene} beat={beat} />); 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", "data-handoff-phase",
"discover", "discover",
); );
expect(screen.getByRole("region", { name: "prepared agent handoff" })).toHaveAttribute( expect(screen.getByRole("region", { name: "prepared agent request" })).toHaveAttribute(
"data-presentation-surface", "data-presentation-surface",
"editorial", "editorial",
); );
@@ -37,46 +37,17 @@ describe("AgentHandoffScene", () => {
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument(); 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 () => { it("reveals separated user and assistant turns after local submission", async () => {
renderBeat("request"); renderRequestBeat();
await userEvent.click(screen.getByRole("button", { name: "Send" })); await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getAllByText(/report|workflow|prepare/i).length).toBeGreaterThanOrEqual(1); 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.getAllByText(/inspect|capabilities|sources|schemas|let me/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByRole("button", { name: /discover.*4 tool calls/i })).toBeInTheDocument(); 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", () => { it("does not render prepared workflow lifecycle content", () => {
renderBeat("request"); renderRequestBeat();
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument(); 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(); expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
}); });
}); });
@@ -1,5 +1,4 @@
import { useReducer } from "react"; import { useReducer } from "react";
import { AuthoringConversation } from "./AuthoringConversation.js";
import { Scene8ChatEntry } from "./Scene8ChatEntry.js"; import { Scene8ChatEntry } from "./Scene8ChatEntry.js";
import { import {
initialScene8EntryState, initialScene8EntryState,
@@ -12,32 +11,17 @@ type AgentHandoffSceneProps = {
readonly beat: SceneBeatDefinition; readonly beat: SceneBeatDefinition;
}; };
/**
* Scene 8 request and handoff beats share one local deterministic entry state.
*/
export const AgentHandoffScene = ({ beat }: AgentHandoffSceneProps) => { export const AgentHandoffScene = ({ beat }: AgentHandoffSceneProps) => {
const [entryState, dispatch] = useReducer(scene8EntryReducer, initialScene8EntryState); const [entryState, dispatch] = useReducer(scene8EntryReducer, initialScene8EntryState);
const phase = beat.id === "handoff" ? "deployment" : "discover";
return ( return (
<section <section
className="agent-handoff-scene" className="agent-handoff-scene"
aria-label="prepared agent handoff" aria-label="prepared agent request"
data-handoff-phase={phase} data-handoff-phase="discover"
data-presentation-surface="editorial" data-presentation-surface="editorial"
> >
{beat.id === "request" ? ( {beat.id === "request" && <Scene8ChatEntry state={entryState} dispatch={dispatch} />}
<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>
)}
</section> </section>
); );
}; };
@@ -157,7 +157,6 @@ const beatContracts = {
"authoring/diagnose": { mode: "evidence", primarySurface: "authoring-diagnostic", supportSurface: "authoring-loop" }, "authoring/diagnose": { mode: "evidence", primarySurface: "authoring-diagnostic", supportSurface: "authoring-loop" },
"authoring/repair": { mode: "evidence", primarySurface: "authoring-repair", 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/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/cohort": { mode: "evidence", primarySurface: "evaluation-cohort", supportSurface: "none" },
"evaluation/validity": { mode: "evidence", primarySurface: "evaluation-validity", supportSurface: "audit-reconciliation" }, "evaluation/validity": { mode: "evidence", primarySurface: "evaluation-validity", supportSurface: "audit-reconciliation" },
"evaluation/findings": { mode: "evidence", primarySurface: "evaluation-findings", supportSurface: "validity-boundary" }, "evaluation/findings": { mode: "evidence", primarySurface: "evaluation-findings", supportSurface: "validity-boundary" },
@@ -3006,22 +3006,6 @@
color: var(--authoring-ink, var(--text-primary)); 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 { .agent-handoff-scene__composer {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -35,6 +35,7 @@ describe("storyboard navigation", () => {
it("falls back for unknown scene, beat, and branch hashes", () => { it("falls back for unknown scene, beat, and branch hashes", () => {
expect(locationFromHash("#scene/missing/nope")).toEqual(defaultMainLocation); expect(locationFromHash("#scene/missing/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#scene/lifecycle/nope")).toEqual(defaultMainLocation); expect(locationFromHash("#scene/lifecycle/nope")).toEqual(defaultMainLocation);
expect(locationFromHash("#scene/agent-handoff/handoff")).toEqual(defaultMainLocation);
expect(locationFromHash("#discuss/nope")).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", () => { 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("agent-handoff", "request")?.chatMode).toBe("hidden");
expect(findBeat("resume-output-evidence", "trace")?.chatMode).toBe("hidden"); expect(findBeat("resume-output-evidence", "trace")?.chatMode).toBe("hidden");
}); });
@@ -154,15 +154,12 @@ export const mainScenes = defineScenes([
{ {
id: "agent-handoff", id: "agent-handoff",
number: 8, number: 8,
title: "Agent Handoff", title: "Agent Request",
claimClass: "implemented", claimClass: "implemented",
evidencePointer: "Constrained demo agent and prepared replay recipe", evidencePointer: "Constrained demo agent and prepared replay recipe",
view: "agent", view: "agent",
beats: [ 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("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" }),
], ],
}, },
{ {