From ec5249bd0bc714b73045b4b50bf705c77296967b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 6 Sep 2026 21:25:27 +0100 Subject: [PATCH] feat(tasks): preview task launch destination and prompt (#3425) --- .../task-create-dialog-effects.test.ts | 2 + .../components/task-create-dialog-effects.ts | 1 + .../task-create-dialog-form-body.test.tsx | 25 +++ .../task-create-dialog-form-body.tsx | 7 + ...k-create-dialog-launch-preview-control.tsx | 62 ++++++ .../task-create-dialog-launch-preview.test.ts | 148 ++++++++++++++ .../task-create-dialog-launch-preview.ts | 71 +++++++ .../task-create-dialog-prop-builders.test.ts | 55 +++++- .../task-create-dialog-prop-builders.ts | 30 ++- .../task-create-dialog-selectors.test.tsx | 101 +++++++++- .../task-create-dialog-selectors.tsx | 76 ++++++-- .../components/task-create-dialog-types.ts | 3 + apps/web/components/task-create-dialog.tsx | 2 + .../components/workflow-selector-row.test.tsx | 59 ++++++ apps/web/components/workflow-selector-row.tsx | 89 +++++++-- apps/web/e2e/tests/task/create-task.spec.ts | 102 ++++++++++ .../mobile-create-task-launch-preview.spec.ts | 115 +++++++++++ apps/web/lib/ws/handlers/workflows.test.ts | 112 ++++++++++- apps/web/lib/ws/handlers/workflows.ts | 64 +++++-- apps/web/src/locales/en/task.json | 6 + apps/web/src/locales/pseudo/task.json | 6 + apps/web/src/locales/pt-pt/task.json | 6 + apps/web/src/locales/zh-cn/task.json | 6 + apps/web/src/locales/zh-hk/task.json | 6 + apps/web/src/locales/zh-tw/task.json | 6 + docs/plans/task-create-launch-preview/plan.md | 128 +++++++++++++ .../task-01-resolve-launch-preview-data.md | 106 +++++++++++ ...task-02-present-launch-preview-controls.md | 133 +++++++++++++ ...ask-03-prove-responsive-launch-previews.md | 93 +++++++++ docs/public/tasks-and-workflows.md | 4 +- docs/specs/tasks/README.md | 2 + .../task-create-launch-preview.md | 110 +++++++++++ .../task-create-launch-preview.md | 180 ++++++++++++++++++ 33 files changed, 1855 insertions(+), 61 deletions(-) create mode 100644 apps/web/components/task-create-dialog-launch-preview-control.tsx create mode 100644 apps/web/components/task-create-dialog-launch-preview.test.ts create mode 100644 apps/web/components/task-create-dialog-launch-preview.ts create mode 100644 apps/web/components/workflow-selector-row.test.tsx create mode 100644 apps/web/e2e/tests/task/mobile-create-task-launch-preview.spec.ts create mode 100644 docs/plans/task-create-launch-preview/plan.md create mode 100644 docs/plans/task-create-launch-preview/task-01-resolve-launch-preview-data.md create mode 100644 docs/plans/task-create-launch-preview/task-02-present-launch-preview-controls.md create mode 100644 docs/plans/task-create-launch-preview/task-03-prove-responsive-launch-previews.md create mode 100644 docs/specs/tasks/requirements/task-create-launch-preview.md create mode 100644 docs/specs/tasks/system-design/task-create-launch-preview.md diff --git a/apps/web/components/task-create-dialog-effects.test.ts b/apps/web/components/task-create-dialog-effects.test.ts index e5128ba7a1..5b269ebff4 100644 --- a/apps/web/components/task-create-dialog-effects.test.ts +++ b/apps/web/components/task-create-dialog-effects.test.ts @@ -70,6 +70,7 @@ describe("useWorkflowStepsEffect", () => { name: "Backlog", position: 0, is_start_step: false, + prompt: "Backlog prompt", }, { id: "visible-start", @@ -97,6 +98,7 @@ describe("useWorkflowStepsEffect", () => { title: "Backlog", position: 0, is_start_step: false, + prompt: "Backlog prompt", events: undefined, workflowId: "visible", }, diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 0b2664b5eb..e47b3c6336 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -53,6 +53,7 @@ export function useWorkflowStepsEffect( workflowId: effectiveWorkflowId, position: s.position, is_start_step: s.is_start_step, + prompt: s.prompt, events: s.events, })), ); diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 5996804163..28f2f75459 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -7,6 +7,7 @@ import { WorkflowSection, } from "./task-create-dialog-form-body"; import type { DialogFormState, TaskFormInputsHandle } from "./task-create-dialog-types"; +import type { TaskCreateLaunchPreview } from "./task-create-dialog-launch-preview"; afterEach(cleanup); @@ -202,6 +203,30 @@ function makeFs(): DialogFormState { }; } +describe("DialogPromptSection launch preview", () => { + it("forwards the launch preview to the prompt composer", () => { + taskFormInputsCalls.length = 0; + const launchPreview: TaskCreateLaunchPreview = { + stepId: "step-1", + stepName: "In Progress", + stepPrompt: "Run {{task_prompt}}", + }; + + render( + {}) as never} + launchPreview={launchPreview} + />, + ); + + expect(taskFormInputsCalls.at(-1)?.launchPreview).toEqual(launchPreview); + }); +}); + describe("DialogPromptSection (CLI-mode parity)", () => { it("keeps a started task prompt locked", () => { taskFormInputsCalls.length = 0; diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 2b336bea14..a0725dcf18 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -16,6 +16,7 @@ import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; import { PromptResultRecovery } from "@/components/prompt-result-recovery"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; +import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview"; import { useTranslation } from "react-i18next"; type SelectorOption = { @@ -384,6 +385,7 @@ type WorkflowSectionProps = { effectiveWorkflowId: string | null; onWorkflowChange: (value: string) => void; agentProfiles: AgentProfileOption[]; + launchPreview?: TaskCreateLaunchPreview | null; /** * When true the picker is hidden entirely. Used by feature wrappers * (Improve Kandev) where the workflow is enforced and the user must not be @@ -401,6 +403,7 @@ function renderWorkflowSection({ effectiveWorkflowId, onWorkflowChange, agentProfiles, + launchPreview, workflowLocked, }: WorkflowSectionProps) { // Hidden workflows (e.g. improve-kandev) are excluded from the picker; they @@ -418,6 +421,7 @@ function renderWorkflowSection({ selectedWorkflowId={effectiveWorkflowId ?? null} onWorkflowChange={onWorkflowChange} agentProfiles={agentProfiles} + launchPreview={launchPreview} /> ); } @@ -485,6 +489,7 @@ export type DialogPromptSectionProps = { descriptionPlaceholder?: string; /** Optional slot rendered above the description textarea (e.g. a tab toggle). */ aboveDescriptionSlot?: React.ReactNode; + launchPreview?: TaskCreateLaunchPreview | null; /** * Whether the description textarea should grab focus on mount. Defaults to * `!isTaskStarted`. Callers that render a task-name input above the @@ -522,6 +527,7 @@ export function DialogPromptSection({ extraFormSlot, descriptionPlaceholder, aboveDescriptionSlot, + launchPreview, autoFocusDescription, onComposerSubmit, }: DialogPromptSectionProps) { @@ -546,6 +552,7 @@ export function DialogPromptSection({ onEnhancePrompt={enhance?.onEnhance} isEnhancingPrompt={enhance?.isLoading} isUtilityConfigured={enhance?.isConfigured} + launchPreview={launchPreview} jiraImport={importBindings(importsEnabled, ws, onJiraImport)} linearImport={importBindings(importsEnabled, ws, onLinearImport)} onComposerSubmit={onComposerSubmit} diff --git a/apps/web/components/task-create-dialog-launch-preview-control.tsx b/apps/web/components/task-create-dialog-launch-preview-control.tsx new file mode 100644 index 0000000000..c89a002a99 --- /dev/null +++ b/apps/web/components/task-create-dialog-launch-preview-control.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { IconEye, IconEyeOff } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; + +type TaskCreateLaunchPreviewToggleProps = { + active: boolean; + disabled?: boolean; + stepName: string; + onToggle: () => void; +}; + +export function TaskCreateLaunchPreviewToggle({ + active, + disabled, + stepName, + onToggle, +}: TaskCreateLaunchPreviewToggleProps) { + const { t } = useTranslation(); + const label = active + ? t("task:editTaskPrompt") + : t("task:previewLaunchPrompt", { step: stepName }); + return ( + + + + + + + {label} + + ); +} + +export function TaskCreateLaunchPreviewContent({ content }: { content: string }) { + const { t } = useTranslation(); + return ( +
+ {content} +
+ ); +} diff --git a/apps/web/components/task-create-dialog-launch-preview.test.ts b/apps/web/components/task-create-dialog-launch-preview.test.ts new file mode 100644 index 0000000000..a8a51502db --- /dev/null +++ b/apps/web/components/task-create-dialog-launch-preview.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + composeLaunchPreviewPrompt, + type TaskCreateLaunchIntent, + resolveTaskCreateLaunchPreview, +} from "./task-create-dialog-launch-preview"; +import type { StepType } from "./task-create-dialog-types"; + +const WORKFLOW_ID = "workflow-1"; + +function step(id: string, position: number, overrides: Partial = {}): StepType { + return { id, title: id, position, ...overrides }; +} + +function resolve( + snapshotSteps: StepType[], + fetchedSteps: StepType[] | null = null, + launchIntent: TaskCreateLaunchIntent = "start-agent", +) { + return resolveTaskCreateLaunchPreview({ + effectiveWorkflowId: WORKFLOW_ID, + fetchedSteps, + snapshotSteps, + launchIntent, + }); +} + +describe("task-create launch preview", () => { + it("selects the first auto-start step by position", () => { + expect( + resolve([ + step("later-auto-start", 3, { + title: "Later automated step", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }), + step("auto-start", 1, { + title: "In Progress", + prompt: "Implement: {{task_prompt}}", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }), + ]), + ).toEqual({ + stepId: "auto-start", + stepName: "In Progress", + stepPrompt: "Implement: {{task_prompt}}", + }); + }); + + it("falls back to the configured start step when no step auto-starts an agent", () => { + expect( + resolve([ + step("first", 0, { title: "First" }), + step("configured-start", 1, { title: "Configured start", is_start_step: true }), + ]), + ).toMatchObject({ stepId: "configured-start", stepName: "Configured start" }); + }); + + it("falls back to the first positional step when no start step is configured", () => { + expect(resolve([step("second", 2), step("first", 1)])).toMatchObject({ + stepId: "first", + stepName: "first", + }); + }); + + it("uses the first positional step for the empty-description plan-mode action", () => { + expect( + resolve( + [ + step("backlog", 0, { title: "Backlog" }), + step("configured", 1, { title: "Configured", is_start_step: true }), + step("auto-start", 2, { + title: "In Progress", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }), + ], + null, + "plan-mode", + ), + ).toMatchObject({ stepId: "backlog", stepName: "Backlog" }); + }); + + it("uses matching fetched steps and never selects a stale workflow step", () => { + expect( + resolve( + [step("snapshot-start", 0, { title: "Snapshot start" })], + [ + step("stale", 0, { + title: "Stale workflow", + workflowId: "old-workflow", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }), + step("current", 1, { + title: "Fetched current", + workflowId: WORKFLOW_ID, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }), + ], + ), + ).toMatchObject({ stepId: "current", stepName: "Fetched current" }); + }); + + it("falls back to the selected workflow snapshot when fetched steps are stale", () => { + expect( + resolve( + [step("selected", 0, { title: "Selected workflow" })], + [step("stale", 0, { workflowId: "old-workflow", title: "Stale workflow" })], + ), + ).toMatchObject({ stepId: "selected", stepName: "Selected workflow" }); + }); + + it("treats an empty successful fetch as authoritative over the snapshot", () => { + expect(resolve([step("stale-snapshot", 0, { title: "Stale snapshot" })], [])).toBeNull(); + }); + + it("omits a destination when there is no effective workflow or no step", () => { + expect( + resolveTaskCreateLaunchPreview({ + effectiveWorkflowId: null, + fetchedSteps: null, + snapshotSteps: [], + }), + ).toBeNull(); + expect(resolve([], [])).toBeNull(); + }); +}); + +describe("composeLaunchPreviewPrompt", () => { + it("replaces only the first task prompt token", () => { + expect( + composeLaunchPreviewPrompt( + "Start {{task_prompt}} then {{task_prompt}} for {task_id} and @saved", + "Review the change", + ), + ).toBe("Start Review the change then {{task_prompt}} for {task_id} and @saved"); + }); + + it("inserts replacement-pattern text literally", () => { + expect( + composeLaunchPreviewPrompt("Start {{task_prompt}} then {{task_prompt}}", "$& and $$"), + ).toBe("Start $& and $$ then {{task_prompt}}"); + }); + + it("uses the complete step prompt when it has no task prompt token", () => { + expect(composeLaunchPreviewPrompt("Run the saved instructions for {task_id}", "Ignored")).toBe( + "Run the saved instructions for {task_id}", + ); + }); +}); diff --git a/apps/web/components/task-create-dialog-launch-preview.ts b/apps/web/components/task-create-dialog-launch-preview.ts new file mode 100644 index 0000000000..a5b450d22a --- /dev/null +++ b/apps/web/components/task-create-dialog-launch-preview.ts @@ -0,0 +1,71 @@ +import type { StepType } from "@/components/task-create-dialog-types"; + +const TASK_PROMPT_TOKEN = "{{task_prompt}}"; + +export type TaskCreateLaunchPreview = { + stepId: string; + stepName: string; + stepPrompt: string; +}; + +export type TaskCreateLaunchIntent = "start-agent" | "plan-mode"; + +type ResolveTaskCreateLaunchPreviewArgs = { + effectiveWorkflowId: string | null; + fetchedSteps: ReadonlyArray | null; + snapshotSteps?: ReadonlyArray; + launchIntent?: TaskCreateLaunchIntent; +}; + +function byPosition(a: StepType, b: StepType): number { + return (a.position ?? Number.MAX_SAFE_INTEGER) - (b.position ?? Number.MAX_SAFE_INTEGER); +} + +function hasAutoStartAction(step: StepType): boolean { + return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false; +} + +export function resolveLaunchPreviewStep( + steps: ReadonlyArray, + launchIntent: TaskCreateLaunchIntent = "start-agent", +): StepType | null { + const ordered = [...steps].sort(byPosition); + if (launchIntent === "plan-mode") return ordered[0] ?? null; + return ( + ordered.find(hasAutoStartAction) ?? + ordered.find((step) => step.is_start_step) ?? + ordered[0] ?? + null + ); +} + +export function resolveTaskCreateLaunchPreview({ + effectiveWorkflowId, + fetchedSteps, + snapshotSteps, + launchIntent = "start-agent", +}: ResolveTaskCreateLaunchPreviewArgs): TaskCreateLaunchPreview | null { + if (!effectiveWorkflowId) return null; + + const matchingFetchedSteps = fetchedSteps?.filter( + (step) => step.workflowId === effectiveWorkflowId, + ); + const steps = + fetchedSteps !== null && + matchingFetchedSteps && + (fetchedSteps.length === 0 || matchingFetchedSteps.length > 0) + ? matchingFetchedSteps + : (snapshotSteps ?? []); + const step = resolveLaunchPreviewStep(steps, launchIntent); + if (!step) return null; + + return { + stepId: step.id, + stepName: step.title, + stepPrompt: step.prompt ?? "", + }; +} + +export function composeLaunchPreviewPrompt(stepPrompt: string, taskPrompt: string): string { + return stepPrompt.replace(TASK_PROMPT_TOKEN, () => taskPrompt); +} diff --git a/apps/web/components/task-create-dialog-prop-builders.test.ts b/apps/web/components/task-create-dialog-prop-builders.test.ts index dbd86e6ea0..fd07d1f9fc 100644 --- a/apps/web/components/task-create-dialog-prop-builders.test.ts +++ b/apps/web/components/task-create-dialog-prop-builders.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; -import type { DialogFormState } from "./task-create-dialog-types"; +import type { DialogFormBodyProps, DialogFormState, StepType } from "./task-create-dialog-types"; import { computeHasAllBranches, localRepositoryCreationEnabled, + resolveDialogLaunchPreview, resolveWorkflowName, } from "./task-create-dialog-prop-builders"; +const WORKFLOW_ID = "workflow-1"; +const IN_PROGRESS = "In Progress"; + function formState(overrides: Partial): DialogFormState { return { noRepository: false, @@ -102,3 +106,52 @@ describe("resolveWorkflowName", () => { expect(resolveWorkflowName(workflows, "wf-missing")).toBeNull(); }); }); + +describe("resolveDialogLaunchPreview", () => { + it("projects the selected workflow snapshot only for create mode", () => { + const steps: StepType[] = [ + { + id: "step-1", + title: IN_PROGRESS, + position: 0, + is_start_step: true, + prompt: "Run {{task_prompt}}", + }, + ]; + const snapshots = { + [WORKFLOW_ID]: { steps }, + } as unknown as DialogFormBodyProps["snapshots"]; + + expect(resolveDialogLaunchPreview(true, WORKFLOW_ID, null, snapshots, true)).toEqual({ + stepId: "step-1", + stepName: IN_PROGRESS, + stepPrompt: "Run {{task_prompt}}", + }); + expect(resolveDialogLaunchPreview(false, WORKFLOW_ID, null, snapshots, true)).toBeNull(); + }); + + it("projects the first positional step for the empty-description plan-mode action", () => { + const steps: StepType[] = [ + { id: "backlog", title: "Backlog", position: 0, prompt: "Queue it" }, + { + id: "auto-start", + title: IN_PROGRESS, + position: 1, + events: { on_enter: [{ type: "auto_start_agent" }] }, + prompt: "Run {{task_prompt}}", + }, + ]; + const snapshots = { + [WORKFLOW_ID]: { steps }, + } as unknown as DialogFormBodyProps["snapshots"]; + + expect(resolveDialogLaunchPreview(true, WORKFLOW_ID, null, snapshots, false)).toMatchObject({ + stepId: "backlog", + stepName: "Backlog", + }); + expect(resolveDialogLaunchPreview(true, WORKFLOW_ID, null, snapshots, true)).toMatchObject({ + stepId: "auto-start", + stepName: IN_PROGRESS, + }); + }); +}); diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts index 0ae429d222..6136166e35 100644 --- a/apps/web/components/task-create-dialog-prop-builders.ts +++ b/apps/web/components/task-create-dialog-prop-builders.ts @@ -11,6 +11,10 @@ import type { TaskCreateDialogProps } from "@/components/task-create-dialog"; import type { useTaskCreateDialogSetup } from "@/components/task-create-dialog-setup"; import type { DialogFormBodyProps, DialogFormState } from "@/components/task-create-dialog-types"; +import { + resolveTaskCreateLaunchPreview, + type TaskCreateLaunchPreview, +} from "@/components/task-create-dialog-launch-preview"; export function computeHasAllBranches(fs: DialogFormState): boolean { if (fs.noRepository) return true; @@ -25,12 +29,29 @@ export function localRepositoryCreationEnabled(isCreateMode: boolean, repoLocked return isCreateMode && !repoLocked; } +export function resolveDialogLaunchPreview( + isCreateMode: boolean, + effectiveWorkflowId: string | null, + fetchedSteps: DialogFormState["fetchedSteps"], + snapshots: DialogFormBodyProps["snapshots"], + hasDescription: boolean, +): TaskCreateLaunchPreview | null { + if (!isCreateMode) return null; + return resolveTaskCreateLaunchPreview({ + effectiveWorkflowId, + fetchedSteps, + snapshotSteps: effectiveWorkflowId ? snapshots[effectiveWorkflowId]?.steps : undefined, + launchIntent: hasDescription ? "start-agent" : "plan-mode", + }); +} + export function buildDialogFormBodyProps( setup: ReturnType, props: TaskCreateDialogProps, ): DialogFormBodyProps { const { fs, computed, handlers } = setup; const repoLocked = !!props.lockedFields?.repository; + const effectiveWorkflowId = computed.effectiveWorkflowId ?? null; return { isSessionMode: setup.isSessionMode, isCreateMode: setup.isCreateMode, @@ -54,7 +75,14 @@ export function buildDialogFormBodyProps( isCreatingTask: fs.isCreatingTask, workflows: setup.workflows, snapshots: setup.snapshots, - effectiveWorkflowId: computed.effectiveWorkflowId ?? null, + effectiveWorkflowId, + launchPreview: resolveDialogLaunchPreview( + setup.isCreateMode, + effectiveWorkflowId, + fs.fetchedSteps, + setup.snapshots, + fs.hasDescription, + ), fs, editDependencies: setup.editDependencies, handleKeyDown: setup.handleKeyDown, diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index 316cceca90..2c37bec67f 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -11,10 +11,15 @@ import { } from "@/components/task/chat/file-attachment"; import { formatBytes } from "@/lib/utils/format-bytes"; import { TaskFormInputs } from "./task-create-dialog-selectors"; +import { TaskCreateLaunchPreviewToggle } from "./task-create-dialog-launch-preview-control"; import type { TaskFormInputsHandle } from "./task-create-dialog-types"; +import type { TaskCreateLaunchPreview } from "./task-create-dialog-launch-preview"; import type { PluginComposerSlotProps } from "@/lib/plugins/types"; const TOAST_MESSAGE_TEST_ID = "toast-message"; +const LAUNCH_PREVIEW_TOGGLE_TEST_ID = "task-create-launch-preview-toggle"; +const DESCRIPTION_INPUT_TEST_ID = "task-description-input"; +const ORIGINAL_PROMPT = "keep this prompt"; vi.mock("@/components/task/chat/file-attachment", async () => { const actual = await vi.importActual( @@ -75,7 +80,11 @@ function Wrapper({ children }: { children: ReactNode }) { ); } -function renderTaskFormInputs(initial: string, strict = false) { +function renderTaskFormInputs( + initial: string, + strict = false, + launchPreview: TaskCreateLaunchPreview | null = null, +) { const ref = createRef(); const form = ( {}} onKeyDown={() => {}} descriptionValueRef={ref} + launchPreview={launchPreview} /> ); const utils = render(strict ? {form} : form, { wrapper: Wrapper }); - const textarea = screen.getByTestId("task-description-input") as HTMLTextAreaElement; + const textarea = screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement; return { ...utils, textarea, ref }; } +describe("TaskFormInputs launch prompt preview", () => { + const launchPreview: TaskCreateLaunchPreview = { + stepId: "step-1", + stepName: "In Progress", + stepPrompt: "Run {{task_prompt}} for {task_id}", + }; + + it("toggles a composed read-only preview without changing the draft", () => { + const { textarea, ref } = renderTaskFormInputs(ORIGINAL_PROMPT, false, launchPreview); + const toggle = screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID); + + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(toggle); + + expect(screen.queryByTestId(DESCRIPTION_INPUT_TEST_ID)).toBeNull(); + expect(screen.getByTestId("task-create-launch-preview-content").textContent).toContain( + `Run ${ORIGINAL_PROMPT} for {task_id}`, + ); + expect(ref.current?.getValue()).toBe(ORIGINAL_PROMPT); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(toggle); + expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe( + ORIGINAL_PROMPT, + ); + expect(textarea).not.toBe(screen.getByTestId(DESCRIPTION_INPUT_TEST_ID)); + expect(ref.current?.getValue()).toBe(ORIGINAL_PROMPT); + }); + + it("does not render a preview toggle without a nonempty step prompt", () => { + renderTaskFormInputs(ORIGINAL_PROMPT, false, { + ...launchPreview, + stepPrompt: " ", + }); + + expect(screen.queryByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID)).toBeNull(); + }); + + it("returns to the editor when the launch preview model disappears", () => { + const ref = createRef(); + const renderForm = (preview: TaskCreateLaunchPreview | null) => ( + {}} + onKeyDown={() => {}} + descriptionValueRef={ref} + launchPreview={preview} + /> + ); + const view = render(renderForm(launchPreview), { wrapper: Wrapper }); + + fireEvent.click(screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID)); + view.rerender(renderForm(null)); + + expect(screen.getByTestId(DESCRIPTION_INPUT_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId("task-create-launch-preview-content")).toBeNull(); + expect(screen.queryByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID)).toBeNull(); + expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe( + ORIGINAL_PROMPT, + ); + }); +}); + +describe("TaskCreateLaunchPreviewToggle", () => { + it("keeps a disabled toggle tooltip trigger focusable", () => { + render( + undefined} + />, + { wrapper: Wrapper }, + ); + + const toggle = screen.getByTestId(LAUNCH_PREVIEW_TOGGLE_TEST_ID); + expect(toggle.getAttribute("disabled")).not.toBeNull(); + expect(toggle.getAttribute("aria-label")).toBe( + "Preview launch prompt with workflow step prompt: In Progress", + ); + expect(toggle.parentElement?.getAttribute("tabindex")).toBe("0"); + expect(toggle.parentElement?.classList.contains("inline-flex")).toBe(true); + }); +}); + describe("TaskFormInputs mention keyboard routing", () => { it("handles Escape in capture before a target listener stops the bubble phase", () => { const { textarea } = renderTaskFormInputs(""); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 43f5ef6336..5352c21f1a 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -27,6 +27,12 @@ import { ContextZone } from "@/components/task/chat/context-items/context-zone"; import { MentionMenu } from "@/components/task/chat/mention-menu"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; +import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview"; +import { composeLaunchPreviewPrompt } from "@/components/task-create-dialog-launch-preview"; +import { + TaskCreateLaunchPreviewContent, + TaskCreateLaunchPreviewToggle, +} from "@/components/task-create-dialog-launch-preview-control"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { JiraImportBar } from "@/components/jira/jira-import-bar"; import { LinearImportBar } from "@/components/linear/linear-import-bar"; @@ -284,6 +290,7 @@ type TaskFormInputsProps = { onEnhancePrompt?: () => void; isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; + launchPreview?: TaskCreateLaunchPreview | null; jiraImport?: { workspaceId: string | null; disabled?: boolean; @@ -640,6 +647,9 @@ type FormInputsToolbarProps = { onEnhancePrompt?: () => void; isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; + launchPreview?: TaskCreateLaunchPreview | null; + isLaunchPromptPreview: boolean; + onToggleLaunchPromptPreview: () => void; jiraImport?: TaskFormInputsProps["jiraImport"]; linearImport?: TaskFormInputsProps["linearImport"]; pluginActions?: React.ReactNode; @@ -651,6 +661,9 @@ function FormInputsToolbar({ onEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, + launchPreview, + isLaunchPromptPreview, + onToggleLaunchPromptPreview, jiraImport, linearImport, pluginActions, @@ -665,6 +678,14 @@ function FormInputsToolbar({ isConfigured={isUtilityConfigured} /> )} + {launchPreview?.stepPrompt.trim() && ( + + )} {jiraImport && ( { + if (!hasLaunchPromptPreview) setIsLaunchPromptPreview(false); + }, [hasLaunchPromptPreview]); const { attachments, isDragging, @@ -888,6 +915,10 @@ export const TaskFormInputs = memo(function TaskFormInputs({ insertAtCursor, submit: onComposerSubmit, }); + const launchPromptPreview = + hasLaunchPromptPreview && launchPreview + ? composeLaunchPreviewPrompt(launchPreview.stepPrompt, description) + : ""; return (
0 ? "ring-0" : ""}`} > -