feat: add scene 9 staged message surface

This commit is contained in:
lda
2026-07-12 07:56:00 +07:00 Verified
parent 7b9817fe45
commit 7615dfaf9b
7 changed files with 316 additions and 38 deletions
@@ -4,12 +4,14 @@ import {
projectPreparedAuthoringThread,
type AuthoringPhaseId,
} from "./authoring-recording.js";
import type { Scene9SubmittedOverrides } from "./scene9-message-state.js";
type AuthoringConversationProps = {
readonly throughPhase: AuthoringPhaseId;
readonly activePhase: AuthoringPhaseId;
readonly surface: "stage" | "dock";
readonly requestOverride?: string | undefined;
readonly requestOverrides?: Scene9SubmittedOverrides | undefined;
readonly scrollMode?: "active" | "start" | undefined;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
};
@@ -20,13 +22,14 @@ export const AuthoringConversation = ({
activePhase,
surface,
requestOverride,
requestOverrides,
scrollMode,
runAction,
}: AuthoringConversationProps) => (
<AssistantOperatorThread
mode={surface === "stage" ? "full" : "dock"}
surface={surface}
messages={projectPreparedAuthoringThread(throughPhase, requestOverride)}
messages={projectPreparedAuthoringThread(throughPhase, requestOverride, requestOverrides)}
activeToolGroupId={authoringToolGroupId(activePhase)}
scrollMode={scrollMode}
ariaLabel="prepared authoring conversation"
@@ -1,7 +1,14 @@
import { useReducer } from "react";
import { projectPreparedAuthoringPhase } from "./authoring-projection.js";
import { AuthoringPhaseVisual } from "./AuthoringPhaseVisual.js";
import { PresentationAssistantPane } from "./PresentationAssistantPane.js";
import type { AuthoringPhaseId } from "./authoring-recording.js";
import {
initialScene9MessageState,
projectScene9Message,
projectScene9SubmittedOverrides,
scene9MessageReducer,
} from "./scene9-message-state.js";
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
import { StageCaption } from "../StageCaption.js";
@@ -25,6 +32,10 @@ const phases: readonly { readonly id: AuthoringPhaseId; readonly label: string }
* projection sourced from the prepared authoring recording.
*/
export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthoringLifecycleSceneProps) => {
const [messageState, dispatch] = useReducer(
scene9MessageReducer,
initialScene9MessageState,
);
// Storyboard beats normally match these IDs. Discovery is a safe projection
// if a future beat reaches this scene before its authoring mapping is added.
const beatId = phases.find((phase) => phase.id === beat.id)?.id ?? "discover";
@@ -43,7 +54,18 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthori
data-support-surface="prepared-chat"
data-presentation-surface="editorial"
>
<PresentationAssistantPane phase={beatId} />
<PresentationAssistantPane
phase={beatId}
message={projectScene9Message(messageState, beatId)}
submittedOverrides={projectScene9SubmittedOverrides(messageState)}
runRequested={messageState.runRequested}
onDraftChange={(draft) => dispatch({ type: "draft_edited", draft })}
onSubmit={() => {
if (beatId === "draft") dispatch({ type: "draft_submitted" });
if (beatId === "artifact") dispatch({ type: "artifact_submitted" });
if (beatId === "deployment") dispatch({ type: "run_requested" });
}}
/>
<div className="prepared-lifecycle-scene__presentation">
<ol className="prepared-lifecycle-scene__rail" aria-label="authoring phase rail">
{phases.map((phase) => (
@@ -1,12 +1,40 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PresentationAssistantPane } from "./PresentationAssistantPane.js";
import {
SCENE9_PHASE_PLACEHOLDERS,
SCENE9_PHASE_PROMPTS,
initialScene9MessageState,
projectScene9Message,
} from "./scene9-message-state.js";
afterEach(cleanup);
const renderPane = (
phase: Parameters<typeof projectScene9Message>[1],
options: Partial<ComponentProps<typeof PresentationAssistantPane>> = {},
) => {
const onDraftChange = options.onDraftChange ?? (() => undefined);
const onSubmit = options.onSubmit ?? (() => undefined);
return render(
<PresentationAssistantPane
phase={phase}
message={projectScene9Message(initialScene9MessageState, phase)}
submittedOverrides={{}}
runRequested={null}
onDraftChange={onDraftChange}
onSubmit={onSubmit}
{...options}
/>,
);
};
describe("PresentationAssistantPane", () => {
it("renders a persistent prepared replay surface for the current phase", () => {
render(<PresentationAssistantPane phase="validate" />);
renderPane("validate");
expect(screen.getByRole("complementary", { name: /prepared authoring assistant/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /authoring assistant/i })).toBeInTheDocument();
@@ -15,7 +43,7 @@ describe("PresentationAssistantPane", () => {
});
it("keeps the active tool group synchronized with the phase", () => {
render(<PresentationAssistantPane phase="artifact" />);
renderPane("artifact");
expect(screen.getByRole("button", { name: /artifact.*3 tool calls/i }))
.toHaveAttribute("aria-expanded", "true");
@@ -23,10 +51,87 @@ describe("PresentationAssistantPane", () => {
.toHaveAttribute("aria-expanded", "false");
});
it("does not expose a live or run action", () => {
render(<PresentationAssistantPane phase="deployment" />);
it("renders an empty, accessible message surface for empty phases", () => {
for (const phase of ["discover", "validate"] as const) {
cleanup();
renderPane(phase);
expect(screen.queryByRole("button", { name: /run|send|execute/i })).not.toBeInTheDocument();
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
expect(input).toHaveValue("");
expect(input).toHaveAttribute("placeholder", SCENE9_PHASE_PLACEHOLDERS[phase]);
expect(screen.getByRole("button", { name: /send message/i })).toBeDisabled();
}
});
it("prefills every staged request with its exact prompt", () => {
for (const phase of ["draft", "artifact", "deployment"] as const) {
cleanup();
renderPane(phase);
expect(screen.getByRole("textbox", { name: /message to authoring assistant/i }))
.toHaveValue(SCENE9_PHASE_PROMPTS[phase]);
}
});
it("preserves edits until submit and submits the edited value", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderPane("draft", { onSubmit });
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
await user.clear(input);
await user.type(input, "Check only the report binding.");
expect(input).toHaveValue("Check only the report binding.");
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(onSubmit).toHaveBeenCalledWith("Check only the report binding.");
});
it("uses Shift+Enter for newlines and Enter to submit", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderPane("artifact", { onSubmit });
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
await user.clear(input);
await user.type(input, "first");
await user.keyboard("{Shift>}{Enter}{/Shift}");
await user.type(input, "second");
expect(input).toHaveValue("first\nsecond");
expect(onSubmit).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onSubmit).toHaveBeenCalledWith("first\nsecond");
});
it("ignores blank submissions and disables the terminal request after submission", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderPane("deployment", { onSubmit });
const input = screen.getByRole("textbox", { name: /message to authoring assistant/i });
await user.clear(input);
expect(screen.getByRole("button", { name: /send message/i })).toBeDisabled();
await user.click(screen.getByRole("button", { name: /send message/i }));
expect(onSubmit).not.toHaveBeenCalled();
cleanup();
renderPane("deployment", { runRequested: "Run this deployment" });
expect(screen.getByRole("button", { name: /send message/i })).toBeDisabled();
});
it("renders submitted overrides while keeping the replay tools canonical", () => {
renderPane("validate", { submittedOverrides: { validate: "Edited validation request" } });
expect(screen.getByText("Edited validation request")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /workflow\.draft_workspaces\.validate/i }))
.toBeInTheDocument();
});
it("does not expose a live or run action", () => {
renderPane("deployment");
expect(screen.queryByRole("button", { name: /run|execute/i })).not.toBeInTheDocument();
expect(screen.queryByText(/workflow\.runs\.start/i)).not.toBeInTheDocument();
});
});
@@ -1,8 +1,20 @@
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { Button } from "../../components/ui/button.js";
import { Textarea } from "../../components/ui/textarea.js";
import { AuthoringConversation } from "./AuthoringConversation.js";
import type { AuthoringPhaseId } from "./authoring-recording.js";
import type {
Scene9MessageProjection,
Scene9SubmittedOverrides,
} from "./scene9-message-state.js";
type PresentationAssistantPaneProps = {
export type PresentationAssistantPaneProps = {
readonly phase: AuthoringPhaseId;
readonly message: Scene9MessageProjection;
readonly submittedOverrides: Scene9SubmittedOverrides;
readonly runRequested: string | null;
readonly onDraftChange: (draft: string) => void;
readonly onSubmit: (message: string) => void;
};
const phaseLabels: Readonly<Record<AuthoringPhaseId, string>> = {
@@ -16,30 +28,87 @@ const phaseLabels: Readonly<Record<AuthoringPhaseId, string>> = {
/**
* Stable Scene 9 boundary for the prepared assistant surface.
*
* The pane deliberately passes no action handlers: its conversation is a
* replay projection, not a live assistant runtime or authoring client.
* The pane owns only the transient composer buffer. Submitted text is handed
* back to the Scene 9 controller so the replay can project it later.
*/
export const PresentationAssistantPane = ({ phase }: PresentationAssistantPaneProps) => (
<aside
className="presentation-assistant-pane"
aria-label="prepared authoring assistant"
data-phase={phase}
data-surface="prepared-replay"
>
<header className="presentation-assistant-pane__header">
<p className="presentation-assistant-pane__eyebrow">Prepared workflow</p>
<h2>Authoring assistant</h2>
<p>Current phase: {phaseLabels[phase]}</p>
<p className="presentation-assistant-pane__disclosure">
Prepared replay only. No live actions or RPC calls.
</p>
</header>
<div className="presentation-assistant-pane__conversation">
<AuthoringConversation
throughPhase={phase}
activePhase={phase}
surface="stage"
/>
</div>
</aside>
);
export const PresentationAssistantPane = ({
phase,
message,
submittedOverrides,
runRequested,
onDraftChange,
onSubmit,
}: PresentationAssistantPaneProps) => {
const [draft, setDraft] = useState(message.prefill);
useEffect(() => {
setDraft(message.prefill);
}, [phase, message.prefill]);
const canSubmit = draft.trim().length > 0 && runRequested === null;
const updateDraft = (nextDraft: string) => {
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();
}
};
return (
<aside
className="presentation-assistant-pane"
aria-label="prepared authoring assistant"
data-phase={phase}
data-surface="prepared-replay"
>
<header className="presentation-assistant-pane__header">
<p className="presentation-assistant-pane__eyebrow">Prepared workflow</p>
<h2>Authoring assistant</h2>
<p>Current phase: {phaseLabels[phase]}</p>
<p className="presentation-assistant-pane__disclosure">
Prepared replay only. No live actions or RPC calls.
</p>
</header>
<div className="presentation-assistant-pane__conversation">
<AuthoringConversation
throughPhase={phase}
activePhase={phase}
surface="stage"
requestOverrides={submittedOverrides}
/>
</div>
<form className="presentation-assistant-pane__composer" onSubmit={submit}>
<label htmlFor="scene9-authoring-message">Message to authoring assistant</label>
<Textarea
id="scene9-authoring-message"
value={draft}
placeholder={message.placeholder}
disabled={runRequested !== null}
onChange={(event) => updateDraft(event.target.value)}
onKeyDown={handleKeyDown}
aria-describedby="scene9-authoring-message-help"
/>
<div className="presentation-assistant-pane__composer-actions">
<Button type="submit" disabled={!canSubmit}>
Send message
</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.
</p>
{runRequested !== null ? (
<p role="status" className="presentation-assistant-pane__run-status">
Run request prepared for the next execution slice.
</p>
) : null}
</form>
</aside>
);
};
@@ -178,4 +178,31 @@ describe("projectPreparedAuthoringThread", () => {
]);
expect(overriddenTools).toEqual(canonicalTools);
});
it("projects staged overrides onto their phase user turns", () => {
const messages = projectPreparedAuthoringThread("validate", undefined, {
validate: "Edited validation request",
});
const validateUser = messages.find(
(message) => message.id === "authoring-validate-message-0",
);
expect(validateUser?.parts).toEqual([{ type: "text", text: "Edited validation request" }]);
});
it("projects both staged overrides without changing tool messages", () => {
const canonical = projectPreparedAuthoringThread("deployment");
const overridden = projectPreparedAuthoringThread("deployment", undefined, {
validate: "Edited validation request",
deployment: "Edited deployment request",
});
expect(overridden.find((message) => message.id === "authoring-validate-message-0")?.parts)
.toEqual([{ type: "text", text: "Edited validation request" }]);
expect(overridden.find((message) => message.id === "authoring-deployment-message-0")?.parts)
.toEqual([{ type: "text", text: "Edited deployment request" }]);
expect(overridden.filter((message) => message.id.endsWith("-tools"))).toEqual(
canonical.filter((message) => message.id.endsWith("-tools")),
);
});
});
@@ -281,19 +281,28 @@ export const authoringToolGroupId = (phase: AuthoringPhaseId): string =>
export const projectPreparedAuthoringThread = (
throughPhase: AuthoringPhaseId = "deployment",
requestOverride?: string,
requestOverrides?: Readonly<Partial<Record<"validate" | "deployment", string>>>,
): readonly AgentMessage[] => {
const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase);
if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`);
let requestReplaced = false;
return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => {
const phaseOverride =
phase.phase === "validate"
? requestOverrides?.validate
: phase.phase === "deployment"
? requestOverrides?.deployment
: undefined;
const conversation = phase.conversation.map((turn, index) => {
const shouldReplaceRequest = requestOverride !== undefined && !requestReplaced && turn.role === "user";
if (shouldReplaceRequest) requestReplaced = true;
const shouldReplaceRequest =
turn.role === "user" &&
(phaseOverride !== undefined || (requestOverride !== undefined && !requestReplaced));
if (shouldReplaceRequest && phaseOverride === undefined) requestReplaced = true;
return agentTextMessage(
`authoring-${phase.phase}-message-${index}`,
turn.role,
shouldReplaceRequest ? requestOverride : turn.text,
shouldReplaceRequest ? (phaseOverride ?? requestOverride ?? turn.text) : turn.text,
);
});
const groupId = authoringToolGroupId(phase.phase);
@@ -3308,6 +3308,49 @@
overflow: auto;
}
.presentation-assistant-pane__composer {
flex: 0 0 auto;
display: grid;
gap: 0.45rem;
padding: 0.7rem 0.9rem 0.85rem;
border-top: 1px solid var(--stage-line);
}
.presentation-assistant-pane__composer label {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 600;
}
.presentation-assistant-pane__composer textarea {
min-height: 4.5rem;
resize: vertical;
font-size: 0.78rem;
}
.presentation-assistant-pane__composer-actions {
display: flex;
justify-content: flex-end;
}
.presentation-assistant-pane__composer-actions button {
height: 2rem;
padding-inline: 0.7rem;
font-size: 0.72rem;
}
.presentation-assistant-pane__composer-help,
.presentation-assistant-pane__run-status {
margin: 0;
color: var(--text-secondary);
font-size: 0.65rem;
line-height: 1.35;
}
.presentation-assistant-pane__run-status {
color: var(--accent-cyan);
}
.prepared-lifecycle-scene[data-presentation-surface="editorial"] {
--authoring-paper: var(--color-editorial-paper, oklch(0.975 0.012 82));
--authoring-ink: var(--color-editorial-ink, oklch(0.19 0.015 65));