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