feat: add scene 8 chat entry surface
This commit is contained in:
@@ -9,6 +9,7 @@ type AuthoringConversationProps = {
|
|||||||
readonly throughPhase: AuthoringPhaseId;
|
readonly throughPhase: AuthoringPhaseId;
|
||||||
readonly activePhase: AuthoringPhaseId;
|
readonly activePhase: AuthoringPhaseId;
|
||||||
readonly surface: "stage" | "dock";
|
readonly surface: "stage" | "dock";
|
||||||
|
readonly requestOverride?: string | undefined;
|
||||||
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
|
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,12 +18,13 @@ export const AuthoringConversation = ({
|
|||||||
throughPhase,
|
throughPhase,
|
||||||
activePhase,
|
activePhase,
|
||||||
surface,
|
surface,
|
||||||
|
requestOverride,
|
||||||
runAction,
|
runAction,
|
||||||
}: AuthoringConversationProps) => (
|
}: AuthoringConversationProps) => (
|
||||||
<AssistantOperatorThread
|
<AssistantOperatorThread
|
||||||
mode={surface === "stage" ? "full" : "dock"}
|
mode={surface === "stage" ? "full" : "dock"}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
messages={projectPreparedAuthoringThread(throughPhase)}
|
messages={projectPreparedAuthoringThread(throughPhase, requestOverride)}
|
||||||
activeToolGroupId={authoringToolGroupId(activePhase)}
|
activeToolGroupId={authoringToolGroupId(activePhase)}
|
||||||
ariaLabel="prepared authoring conversation"
|
ariaLabel="prepared authoring conversation"
|
||||||
runAction={runAction}
|
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", () => {
|
it("uses a stable phase tool-group id", () => {
|
||||||
expect(authoringToolGroupId("validate")).toBe("authoring-validate");
|
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 = (
|
export const projectPreparedAuthoringThread = (
|
||||||
throughPhase: AuthoringPhaseId = "deployment",
|
throughPhase: AuthoringPhaseId = "deployment",
|
||||||
|
requestOverride?: string,
|
||||||
): readonly AgentMessage[] => {
|
): readonly AgentMessage[] => {
|
||||||
const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase);
|
const finalPhaseIndex = recording.findIndex(({ phase }) => phase === throughPhase);
|
||||||
if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`);
|
if (finalPhaseIndex < 0) throw new Error(`unknown phase: ${throughPhase}`);
|
||||||
|
|
||||||
|
let requestReplaced = false;
|
||||||
return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => {
|
return recording.slice(0, finalPhaseIndex + 1).flatMap((phase) => {
|
||||||
const conversation = phase.conversation.map((turn, index) =>
|
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 groupId = authoringToolGroupId(phase.phase);
|
||||||
const toolParts = phase.commands.flatMap((command, index) => {
|
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);
|
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 {
|
.agent-handoff-scene__composer-label {
|
||||||
color: var(--authoring-muted, var(--text-secondary));
|
color: var(--authoring-muted, var(--text-secondary));
|
||||||
font: 700 0.68rem/1 var(--font-evidence);
|
font: 700 0.68rem/1 var(--font-evidence);
|
||||||
|
|||||||
Reference in New Issue
Block a user