src/features/artifact/components/chat/ClarifyPanel.tsx
export function ClarifyForm({
onSubmit,
onAttach,
+ initialAnswers,
}: {
questions: { request: ClarifyRequest; context?: string }[];
export function ClarifyForm({
onSubmit: (answers: Record<string, string>) => void;
onAttach: (file: File) => Promise<string>;
+ /** Starting answers by `toolCallId`, e.g. a workflow Ask step's previous answers when it asks again. */
+ initialAnswers?: Record<string, string>;
}) {
const t = useTranslations("artifactChat.clarify");
- const [answers, setAnswers] = useState<Record<string, string>>({});
+ const [answers, setAnswers] = useState<Record<string, string>>(
+ () => initialAnswers ?? {}
+ );
const [uploads, setUploads] = useState<Record<string, boolean>>({});
const compose = useGateProps("content.write");
src/features/workflow/__tests__/ask-prefill.test.tsx
…
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import {
+ WorkflowAttemptState,
+ WorkflowNodeType,
+ WorkflowSessionStatus,
+} from "@/shared/interfaces/generated/workflow";
+import type { WorkflowGraph } from "@/shared/interfaces/generated/workflow";
+
+import {
+ attemptOf,
+ definitionOf,
+ installBackend,
+ installDomShims,
+ renderEditor,
+ sessionOf,
+ step,
+} from "./editorHarness";
+
+vi.mock("@/libs/api", () =>
+ import("@test/helpers/api").then((m) => m.apiModule())
+);
+vi.mock("next/navigation", () =>
+ import("@test/helpers/router").then((m) => m.routerModule())
+);
+vi.mock("@/utils/toast", () =>
+ import("@test/helpers/toast").then((m) => m.toastModule())
+);
+vi.mock("../hooks/useWorkflowStream", () => ({
+ useWorkflowStream: () => ({ version: 0, connected: true }),
+}));
+vi.mock("@xyflow/react", async () => ({
+ ...(await vi.importActual<object>("@xyflow/react")),
+ Background: () => null,
+ MiniMap: () => null,
+}));
+
+beforeEach(() => {
+ installDomShims();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+const fields = {
+ fields: [
+ { key: "product", question: "Which product?", required: true },
+ { key: "offer", question: "What is the offer?", required: true },
+ ],
+};
+const GRAPH: WorkflowGraph = {
+ nodes: [step("brief", WorkflowNodeType.Ask, "Brief", fields, 0)],
+ edges: [],
+};
+
+describe("[NO-CB] an Ask step that asks again starts from its last answers", () => {
+ // Symptom: Rerun from here on an Ask emptied every field, so changing one answer meant retyping all.
+ // Breaks if: the questions form stops reading the step's last completed attempt at the same path.
+ it("prefills the earlier answers, and Continue sends them without retyping", async () => {
+ const run = sessionOf(
+ GRAPH,
+ [
+ attemptOf("brief", {
+ id: "att-1",
+ ordinal: 1,
+ output: { offer: "50% off", product: "Green SM Taxi" },
+ }),
+ attemptOf("brief", {
+ id: "att-2",
+ ordinal: 2,
+ state: WorkflowAttemptState.AwaitingInput,
+ resolvedInput: fields,
+ finishedAt: null,
+ }),
+ ],
+ { status: WorkflowSessionStatus.AwaitingInput }
+ );
+ const backend = installBackend({
+ definition: definitionOf(GRAPH),
+ runs: [run],
+ route: (method, url) =>
+ method === "post" && url.endsWith("/actions") ? run : undefined,
+ });
+ const user = userEvent.setup({ delay: null });
+ renderEditor({ run: "run-1" });
+
+ const card = await waitFor(() => {
+ const found = document.querySelector<HTMLElement>(
+ '.react-flow__node[data-id="brief"]'
+ );
+ expect(found).not.toBeNull();
+ return found!;
+ });
+ fireEvent.click(within(card).getByText("Answer"));
+ const sheet = await screen.findByLabelText("Brief inspector");
+ const product = within(sheet).getByRole("textbox", {
+ name: "Which product?",
+ });
+ const offer = within(sheet).getByRole("textbox", {
+ name: "What is the offer?",
+ });
+ expect(product).toHaveTextContent("Green SM Taxi");
+ expect(offer).toHaveTextContent("50% off");
+
+ await user.click(
+ within(sheet).getByRole("button", { name: "Continue workflow" })
+ );
+
+ await waitFor(() =>
+ expect(
+ backend.requests.filter((r) => r.url.endsWith("/actions"))
+ ).toHaveLength(1)
+ );
+ const body = backend.requests.find((r) => r.url.endsWith("/actions"))!
+ .body as { answers: Record<string, Record<string, unknown>> };
+ expect(body.answers).toEqual({
+ brief: { product: "Green SM Taxi", offer: "50% off" },
+ });
+ });
+});
src/features/workflow/components/WorkflowReview.tsx
export function AskFieldsForm({
scrollable,
workflowSessionId,
+ previous,
onSubmit,
}: {
export function AskFieldsForm({
groups: AskGroup[];
workflowSessionId?: string;
+ /** Earlier answers by `group.key` then field key; text and choice answers prefill the form. */
+ previous?: Record<string, Record<string, unknown>>;
submitLabel: string;
pending: boolean;
export function AskFieldsForm({
submitLabel={submitLabel}
onAttach={(file) => workflowAnswerFile(brandId, file, workflowSessionId)}
+ initialAnswers={Object.fromEntries(
+ groups.flatMap((group) =>
+ group.fields.flatMap((field) => {
+ const value = previous?.[group.key]?.[field.key];
+ return typeof value === "string" && value && value !== SKIPPED
+ ? [[questionId(group, field), value]]
+ : [];
+ })
+ )
+ )}
questions={groups.flatMap((group) =>
group.fields.map((field) => ({
export function WorkflowQuestions({
<p className="text-sm text-ink/60">{t("questions.nothingWaiting")}</p>
);
+ // Asked again (Rerun from here, Ask again): start from the step's last completed answers at
+ // the same path instead of an empty form, so changing one answer is not retyping them all.
+ const previous = Object.fromEntries(
+ waiting.flatMap((attempt) => {
+ const last = run.attempts
+ .filter(
+ (entry) =>
+ entry.nodeId === attempt.nodeId &&
+ entry.path === attempt.path &&
+ entry.state === WorkflowAttemptState.Completed &&
+ entry.output &&
+ typeof entry.output === "object" &&
+ !Array.isArray(entry.output)
+ )
+ .sort((a, b) => a.ordinal - b.ordinal)
+ .at(-1);
+ return last
+ ? [[attempt.path, last.output as Record<string, unknown>]]
+ : [];
+ })
+ );
return (
<AskFieldsForm
+ previous={previous}
scrollable
brandId={brandId}