feat: wire scene 8 chat entry beats

This commit is contained in:
lda
2026-07-12 05:09:06 +07:00 Verified
parent 127fa99413
commit 3158060211
6 changed files with 60 additions and 184 deletions
@@ -1,8 +1,9 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineAgentController } from "../../demo/agent/timelineAgent.js";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
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 scene = findScene("agent-handoff");
@@ -14,7 +15,7 @@ const renderBeat = (beatId: "request" | "handoff") => {
afterEach(cleanup);
describe("AgentHandoffScene", () => {
it("renders one prepared conversation with a phase orientation rail", () => {
it("renders the empty request beat as a chat composer", () => {
render(
<AgentHandoffScene
scene={findScene("agent-handoff")!}
@@ -30,14 +31,10 @@ describe("AgentHandoffScene", () => {
"data-presentation-surface",
"editorial",
);
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toHaveAttribute(
"data-surface",
"stage",
);
expect(screen.getByText(/We need to author a report workflow/i)).toBeInTheDocument();
expect(screen.getByText(/Let me inspect the available sources/i)).toBeInTheDocument();
expect(screen.getByRole("list", { name: "prepared handoff phases" })).toBeInTheDocument();
expect(screen.getAllByText(/workflow\.sources\.list/i).length).toBeGreaterThan(0);
expect(screen.getByRole("heading", { name: /what should the workflow author prepare/i })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /authoring request/i })).toHaveValue(SCENE8_REQUEST);
expect(screen.queryByRole("list", { name: "prepared handoff phases" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
});
it("advances the same conversation to deployment evidence", () => {
@@ -55,10 +52,12 @@ describe("AgentHandoffScene", () => {
expect(screen.getAllByText(/workflow\.deployments\.save/i).length).toBeGreaterThan(0);
});
it("renders separated user and assistant turns on the request beat", () => {
it("reveals separated user and assistant turns after local submission", async () => {
renderBeat("request");
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", () => {
@@ -76,28 +75,8 @@ describe("AgentHandoffScene", () => {
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
});
it("exposes the live prepared-workflow action inside the visible conversation", () => {
const runPreparedWorkflow = vi.fn();
const timelineAgent = {
messages: [],
canRun: true,
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
} as unknown as TimelineAgentController;
render(
<AgentHandoffScene
scene={findScene("agent-handoff")!}
beat={findBeat("agent-handoff", "request")!}
timelineAgent={timelineAgent}
/>,
);
const action = screen.getByRole("button", { name: "Run prepared workflow" });
expect(action).toBeEnabled();
action.click();
expect(runPreparedWorkflow).toHaveBeenCalledOnce();
it("does not expose a workflow run action", () => {
renderBeat("handoff");
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
});
});
@@ -1,60 +1,43 @@
import { useReducer } from "react";
import { AuthoringConversation } from "./AuthoringConversation.js";
import type { AuthoringPhaseId } from "./authoring-recording.js";
import type { TimelineAgentController } from "../../demo/agent/timelineAgent.js";
import { Scene8ChatEntry } from "./Scene8ChatEntry.js";
import {
initialScene8EntryState,
scene8EntryReducer,
} from "./scene8-entry-state.js";
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
type AgentHandoffSceneProps = {
readonly scene: SceneDefinition;
readonly beat: SceneBeatDefinition;
readonly timelineAgent?: TimelineAgentController | undefined;
};
/**
* Full-screen prepared-authoring conversation for Scene 8.
*
* The request beat shows the operator asking the agent to prepare a report;
* the handoff beat reveals the full completed conversation with all phases.
* The conversation is a prepared recording, but the shared run action can
* start the configured live workflow when a target is available.
* Scene 8 request and handoff beats share one local deterministic entry state.
*/
export const AgentHandoffScene = ({ beat, timelineAgent }: AgentHandoffSceneProps) => {
const phase: AuthoringPhaseId = beat.id === "handoff" ? "deployment" : "discover";
const phases: readonly { readonly id: AuthoringPhaseId; readonly label: string }[] = [
{ id: "discover", label: "Discover" },
{ id: "draft", label: "Draft" },
{ id: "validate", label: "Validate" },
{ id: "artifact", label: "Artifact" },
{ id: "deployment", label: "Deployment" },
];
const activeIndex = phases.findIndex(({ id }) => id === phase);
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} data-presentation-surface="editorial">
<header className="agent-handoff-scene__header">
<span>Prepared replay</span>
<strong>External agent workflow substrate</strong>
<p>One conversation, with public operations and interpreted results.</p>
</header>
<ol className="agent-handoff-scene__rail" aria-label="prepared handoff phases">
{phases.map((item, index) => (
<li key={item.id} data-active={index === activeIndex ? "true" : "false"}>
<span>{index + 1}</span>
<strong>{item.label}</strong>
</li>
))}
</ol>
<div className="agent-handoff-scene__thread">
<AuthoringConversation
throughPhase={phase}
activePhase={phase}
surface="stage"
runAction={timelineAgent ? {
label: timelineAgent.runLabel,
disabled: !timelineAgent.canRun,
run: () => { void timelineAgent.runPreparedWorkflow(); },
} : undefined}
/>
</div>
<section
className="agent-handoff-scene"
aria-label="prepared agent handoff"
data-handoff-phase={phase}
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>
)}
</section>
);
};