feat: add scene 8 chat entry surface

This commit is contained in:
lda
2026-07-12 05:05:13 +07:00 Verified
parent 640873a8a2
commit 127fa99413
6 changed files with 205 additions and 2 deletions
@@ -9,6 +9,7 @@ type AuthoringConversationProps = {
readonly throughPhase: AuthoringPhaseId;
readonly activePhase: AuthoringPhaseId;
readonly surface: "stage" | "dock";
readonly requestOverride?: string | undefined;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
};
@@ -17,12 +18,13 @@ export const AuthoringConversation = ({
throughPhase,
activePhase,
surface,
requestOverride,
runAction,
}: AuthoringConversationProps) => (
<AssistantOperatorThread
mode={surface === "stage" ? "full" : "dock"}
surface={surface}
messages={projectPreparedAuthoringThread(throughPhase)}
messages={projectPreparedAuthoringThread(throughPhase, requestOverride)}
activeToolGroupId={authoringToolGroupId(activePhase)}
ariaLabel="prepared authoring conversation"
runAction={runAction}
@@ -0,0 +1,51 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { Scene8ChatEntry } from "./Scene8ChatEntry.js";
import {
initialScene8EntryState,
scene8EntryReducer,
type Scene8EntryAction,
type Scene8EntryState,
} from "./scene8-entry-state.js";
afterEach(cleanup);
const renderEntry = (initialState: Scene8EntryState = initialScene8EntryState) => {
let state = initialState;
const dispatch = (action: Scene8EntryAction) => {
state = scene8EntryReducer(state, action);
rerender(<Scene8ChatEntry state={state} dispatch={dispatch} />);
};
const { rerender } = render(<Scene8ChatEntry state={state} dispatch={dispatch} />);
return { dispatch };
};
describe("Scene8ChatEntry", () => {
it("prefills the labeled composer and keeps Send enabled", () => {
renderEntry();
expect(screen.getByRole("textbox", { name: /authoring request/i })).toHaveValue(
initialScene8EntryState.draft,
);
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
});
it("updates the draft and disables Send for whitespace-only input", async () => {
const user = userEvent.setup();
renderEntry();
const textarea = screen.getByRole("textbox", { name: /authoring request/i });
await user.clear(textarea);
await user.type(textarea, " ");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
it("reveals the first Discover group after local submission without a run action", async () => {
const user = userEvent.setup();
renderEntry();
await user.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getByText(/let me inspect the available sources/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /discover.*4 tool calls/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /run prepared workflow/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
});
@@ -0,0 +1,71 @@
import type { FormEvent } from "react";
import { Button } from "../../components/ui/button.js";
import { Textarea } from "../../components/ui/textarea.js";
import { AuthoringConversation } from "./AuthoringConversation.js";
import {
canSubmitScene8Entry,
type Scene8EntryAction,
type Scene8EntryState,
} from "./scene8-entry-state.js";
type Scene8ChatEntryProps = {
readonly state: Scene8EntryState;
readonly dispatch: (action: Scene8EntryAction) => void;
};
/** Scene 8's deterministic request surface; submission reveals prepared data locally. */
export const Scene8ChatEntry = ({ state, dispatch }: Scene8ChatEntryProps) => {
const submitted = state.phase === "submitted";
const canSubmit = canSubmitScene8Entry(state);
const submit = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (canSubmit) dispatch({ type: "submit" });
};
return (
<section className="agent-handoff-scene__entry" aria-label="authoring chat entry">
<div className="agent-handoff-scene__intro">
<span>Scene 8 · agent handoff</span>
<h1>What should the workflow author prepare?</h1>
<p>Ask the external agent to inspect the available sources and capabilities before it authors the workflow.</p>
</div>
<form className="agent-handoff-scene__composer" onSubmit={submit}>
<label className="agent-handoff-scene__composer-label" htmlFor="scene8-authoring-request">
Authoring request
</label>
<Textarea
id="scene8-authoring-request"
value={state.draft}
disabled={submitted}
onChange={(event) => dispatch({ type: "draft_changed", draft: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
aria-describedby="scene8-authoring-request-help"
/>
<div className="agent-handoff-scene__composer-actions">
<Button type="submit" disabled={!canSubmit}>
Send
</Button>
</div>
<p id="scene8-authoring-request-help" className="agent-handoff-scene__composer-help">
Shift+Enter adds a new line. This is a deterministic prepared replay, not a live model request.
</p>
</form>
{submitted ? (
<div className="agent-handoff-scene__entry-thread">
<AuthoringConversation
throughPhase="discover"
activePhase="discover"
surface="stage"
requestOverride={state.request}
/>
</div>
) : null}
</section>
);
};
@@ -152,4 +152,25 @@ describe("projectPreparedAuthoringThread", () => {
it("uses a stable phase tool-group id", () => {
expect(authoringToolGroupId("validate")).toBe("authoring-validate");
});
it("keeps the canonical projection unchanged without a request override", () => {
expect(projectPreparedAuthoringThread("discover")).toEqual(
projectPreparedAuthoringThread("discover"),
);
});
it("overrides only the first user request and preserves Discover tool data", () => {
const canonical = projectPreparedAuthoringThread("discover");
const overridden = projectPreparedAuthoringThread("discover", "A custom request");
const canonicalUser = canonical.find((message) => message.role === "user");
const overriddenUser = overridden.find((message) => message.role === "user");
const canonicalTools = canonical.find((message) => message.id === "authoring-discover-tools");
const overriddenTools = overridden.find((message) => message.id === "authoring-discover-tools");
expect(canonicalUser?.parts).not.toEqual(overriddenUser?.parts);
expect(overriddenUser?.parts).toEqual([
{ type: "text", text: "A custom request" },
]);
expect(overriddenTools).toEqual(canonicalTools);
});
});
@@ -280,13 +280,21 @@ export const authoringToolGroupId = (phase: AuthoringPhaseId): string =>
*/
export const projectPreparedAuthoringThread = (
throughPhase: AuthoringPhaseId = "deployment",
requestOverride?: 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 conversation = phase.conversation.map((turn, index) =>
agentTextMessage(`authoring-${phase.phase}-message-${index}`, turn.role, turn.text),
agentTextMessage(
`authoring-${phase.phase}-message-${index}`,
turn.role,
requestOverride !== undefined && !requestReplaced && turn.role === "user"
? (requestReplaced = true, requestOverride)
: turn.text,
),
);
const groupId = authoringToolGroupId(phase.phase);
const toolParts = phase.commands.flatMap((command, index) => {
@@ -3094,6 +3094,56 @@
box-shadow: 0 1.25rem 3rem color-mix(in oklch, var(--stage-ink) 10%, transparent);
}
.agent-handoff-scene__entry {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 1rem;
min-height: 0;
height: 100%;
}
.agent-handoff-scene__intro {
width: min(100%, 52rem);
margin-inline: auto;
}
.agent-handoff-scene__intro span {
color: var(--authoring-accent, var(--accent-cyan));
font: 700 0.68rem/1 var(--font-evidence);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent-handoff-scene__intro h1 {
margin: 0.45rem 0 0;
color: var(--authoring-ink, var(--text-primary));
font: 700 clamp(1.45rem, 3vw, 2.25rem)/1.05 var(--font-interface);
}
.agent-handoff-scene__intro p {
max-width: 42rem;
margin: 0.55rem 0 0;
color: var(--authoring-muted, var(--text-secondary));
font: 0.9rem/1.4 var(--font-interface);
}
.agent-handoff-scene__composer-help {
margin: 0;
color: var(--authoring-muted, var(--text-secondary));
font: 0.68rem/1.35 var(--font-interface);
}
.agent-handoff-scene__entry-thread {
min-height: 0;
overflow: hidden;
}
.agent-handoff-scene__entry-thread .assistant-operator-thread {
width: 100%;
height: 100%;
max-height: none;
}
.agent-handoff-scene__composer-label {
color: var(--authoring-muted, var(--text-secondary));
font: 700 0.68rem/1 var(--font-evidence);