feat: add scene 9 staged message surface
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user