feat(tasks): preview task launch destination and prompt (#3425)

This commit is contained in:
Carlos Florêncio
2026-09-06 21:25:27 +01:00
committed by GitHub
parent 66b41c8968
commit ec5249bd0b
33 changed files with 1855 additions and 61 deletions
@@ -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",
},
@@ -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,
})),
);
@@ -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(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription="Original prompt"
fs={makeFs()}
handleKeyDown={(() => {}) 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;
@@ -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}
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:bg-muted/40 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
aria-label={label}
aria-pressed={active}
data-testid="task-create-launch-preview-toggle"
disabled={disabled}
onClick={onToggle}
>
{active ? <IconEyeOff /> : <IconEye />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function TaskCreateLaunchPreviewContent({ content }: { content: string }) {
const { t } = useTranslation();
return (
<div
role="textbox"
aria-readonly="true"
aria-label={t("task:launchPromptPreview")}
data-testid="task-create-launch-preview-content"
className="min-h-[96px] max-h-[240px] min-w-0 max-w-full overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] px-3 py-2 text-[13px]"
>
{content}
</div>
);
}
@@ -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> = {}): 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}",
);
});
});
@@ -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<StepType> | null;
snapshotSteps?: ReadonlyArray<StepType>;
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<StepType>,
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);
}
@@ -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>): 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,
});
});
});
@@ -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<typeof useTaskCreateDialogSetup>,
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,
@@ -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<typeof import("@/components/task/chat/file-attachment")>(
@@ -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<TaskFormInputsHandle>();
const form = (
<TaskFormInputs
@@ -85,13 +94,101 @@ function renderTaskFormInputs(initial: string, strict = false) {
onDescriptionChange={() => {}}
onKeyDown={() => {}}
descriptionValueRef={ref}
launchPreview={launchPreview}
/>
);
const utils = render(strict ? <StrictMode>{form}</StrictMode> : 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<TaskFormInputsHandle>();
const renderForm = (preview: TaskCreateLaunchPreview | null) => (
<TaskFormInputs
isSessionMode={false}
autoFocus={false}
initialDescription={ORIGINAL_PROMPT}
onDescriptionChange={() => {}}
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(
<TaskCreateLaunchPreviewToggle
active={false}
disabled
stepName="In Progress"
onToggle={() => 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("");
@@ -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() && (
<TaskCreateLaunchPreviewToggle
active={isLaunchPromptPreview}
disabled={disabled}
stepName={launchPreview.stepName}
onToggle={onToggleLaunchPromptPreview}
/>
)}
{jiraImport && (
<JiraImportBar
workspaceId={jiraImport.workspaceId}
@@ -837,12 +858,18 @@ export const TaskFormInputs = memo(function TaskFormInputs({
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
launchPreview,
jiraImport,
linearImport,
onComposerSubmit,
taskId = null,
}: TaskFormInputsProps) {
const { t } = useTranslation();
const [isLaunchPromptPreview, setIsLaunchPromptPreview] = useState(false);
const hasLaunchPromptPreview = Boolean(launchPreview?.stepPrompt.trim());
useEffect(() => {
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 (
<div
@@ -900,31 +931,38 @@ export const TaskFormInputs = memo(function TaskFormInputs({
className={`min-w-0 max-w-full rounded-md border border-input bg-transparent focus-within:ring-2 focus-within:ring-ring/30 ${contextItems.length > 0 ? "ring-0" : ""}`}
>
<ContextZone items={contextItems} />
<Textarea
ref={textareaRef}
placeholder={
placeholder ??
(isSessionMode
? t("task:describeWhatYouWantTheAgent")
: t("task:writeAPromptForTheAgent"))
}
value={description}
onChange={handleChange}
onKeyDownCapture={handleKeyDownCapture}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-testid="task-description-input"
rows={2}
className={`min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
required={isSessionMode}
disabled={disabled}
/>
{isLaunchPromptPreview ? (
<TaskCreateLaunchPreviewContent content={launchPromptPreview} />
) : (
<Textarea
ref={textareaRef}
placeholder={
placeholder ??
(isSessionMode
? t("task:describeWhatYouWantTheAgent")
: t("task:writeAPromptForTheAgent"))
}
value={description}
onChange={handleChange}
onKeyDownCapture={handleKeyDownCapture}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-testid="task-description-input"
rows={2}
className={`min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
required={isSessionMode}
disabled={disabled}
/>
)}
<FormInputsToolbar
onAttach={handleAttachClick}
disabled={disabled}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
launchPreview={launchPreview}
isLaunchPromptPreview={isLaunchPromptPreview}
onToggleLaunchPromptPreview={() => setIsLaunchPromptPreview((active) => !active)}
jiraImport={jiraImport}
linearImport={linearImport}
pluginActions={pluginActions}
@@ -30,6 +30,7 @@ import type {
useExecutorProfileOptions,
} from "@/components/task-create-dialog-options";
import type { useToast } from "@/components/toast-provider";
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
export type TaskCreateSubmit = (
payload: Parameters<typeof createTask>[0],
@@ -167,6 +168,7 @@ export type StepType = {
workflowId?: string;
position?: number;
is_start_step?: boolean;
prompt?: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
@@ -602,6 +604,7 @@ export type DialogFormBodyProps = {
workflows: WorkflowsState["items"];
snapshots: KanbanMultiState["snapshots"];
effectiveWorkflowId: string | null;
launchPreview: TaskCreateLaunchPreview | null;
fs: DialogFormState;
editDependencies: TaskEditDialogDependenciesState;
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
@@ -108,6 +108,7 @@ function CreateModeBody(props: DialogFormBodyProps) {
onPendingAttachmentUploadsChange={fs.setHasPendingAttachmentUploads}
handleKeyDown={props.handleKeyDown}
enhance={props.enhance}
launchPreview={props.launchPreview}
workspaceId={workspaceId}
onJiraImport={onJiraImport}
onLinearImport={onLinearImport}
@@ -190,6 +191,7 @@ function DialogFormBody(props: DialogFormBodyProps) {
effectiveWorkflowId={props.effectiveWorkflowId}
onWorkflowChange={props.onWorkflowChange}
agentProfiles={props.agentProfiles}
launchPreview={props.launchPreview}
workflowLocked={props.workflowLocked}
/>
<TaskCreateAdvancedSettings
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { WorkflowSelectorRow } from "./workflow-selector-row";
import type { TaskCreateLaunchPreview } from "./task-create-dialog-launch-preview";
afterEach(cleanup);
const launchPreview: TaskCreateLaunchPreview = {
stepId: "step-1",
stepName: "In Progress",
stepPrompt: "Run {{task_prompt}}",
};
function renderSelector(preview: TaskCreateLaunchPreview | null = launchPreview) {
return render(
<TooltipProvider>
<WorkflowSelectorRow
workflows={[{ id: "workflow-1", name: "Development" }]}
snapshots={{}}
selectedWorkflowId="workflow-1"
onWorkflowChange={() => {}}
agentProfiles={[]}
launchPreview={preview}
/>
</TooltipProvider>,
);
}
describe("WorkflowSelectorRow launch destination", () => {
it("shows the launch controls immediately after the selector", () => {
renderSelector();
const trigger = screen.getByTestId("workflow-selector-trigger");
const row = screen.getByTestId("workflow-selector-row");
const launchStep = screen.getByTestId("task-create-launch-step");
expect(trigger.textContent).toContain("Development");
expect(trigger.contains(launchStep)).toBe(false);
expect(row.contains(trigger)).toBe(true);
expect(row.contains(launchStep)).toBe(true);
expect(trigger.className).not.toContain("flex-1");
expect(launchStep.textContent).toBe("Start step: In Progress");
expect(launchStep.className).not.toContain("ml-auto");
const launchStepInfo = screen.getByTestId("task-create-launch-step-info");
expect(trigger.contains(launchStepInfo)).toBe(false);
expect(launchStepInfo.getAttribute("aria-label")).toBe("Learn about the task start step");
expect(Array.from(row.children).indexOf(launchStepInfo)).toBeLessThan(
Array.from(row.children).indexOf(launchStep),
);
});
it("does not show a destination when the selected workflow has no preview", () => {
renderSelector(null);
expect(screen.queryByTestId("task-create-launch-step")).toBeNull();
});
});
+74 -15
View File
@@ -2,13 +2,14 @@
import { Fragment, memo, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconCheck, IconChevronDown, IconLogicBuffer } from "@tabler/icons-react";
import { IconCheck, IconChevronDown, IconInfoCircle, IconLogicBuffer } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Button } from "@kandev/ui/button";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import type { AgentProfileOption } from "@/lib/state/slices";
import { AgentLogo } from "@/components/agent-logo";
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
type StepItem = {
id: string;
@@ -88,16 +89,79 @@ type WorkflowSelectorRowProps = {
selectedWorkflowId: string | null;
onWorkflowChange: (workflowId: string) => void;
agentProfiles: AgentProfileOption[];
launchPreview?: TaskCreateLaunchPreview | null;
clearLabel?: string;
placeholder?: string;
};
function WorkflowSelectorTrigger({
selectedWorkflow,
placeholder,
}: {
selectedWorkflow: WorkflowSelectorRowProps["workflows"][number] | undefined;
placeholder?: string;
}) {
const { t } = useTranslation();
return (
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="min-h-11 w-auto min-w-0 max-w-full justify-between cursor-pointer md:min-h-7"
data-testid="workflow-selector-trigger"
>
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">
{selectedWorkflow?.name ?? placeholder ?? t("workflows:selectWorkflow")}
</span>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
);
}
function LaunchDestinationInfo() {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
aria-label={t("task:launchDestinationHelpLabel")}
data-testid="task-create-launch-step-info"
>
<IconInfoCircle className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[320px] text-xs leading-relaxed">
{t("task:launchDestinationHelp")}
</TooltipContent>
</Tooltip>
);
}
function LaunchDestinationLabel({ stepName }: { stepName: string }) {
const { t } = useTranslation();
return (
<span
className="min-w-0 max-w-[45vw] shrink truncate text-xs text-muted-foreground"
data-testid="task-create-launch-step"
>
{t("task:launchDestination", { step: stepName })}
</span>
);
}
export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
workflows,
snapshots,
selectedWorkflowId,
onWorkflowChange,
agentProfiles,
launchPreview,
clearLabel,
placeholder,
}: WorkflowSelectorRowProps) {
@@ -111,20 +175,15 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="min-h-11 w-auto justify-between cursor-pointer md:min-h-7"
data-testid="workflow-selector-trigger"
>
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{selectedWorkflow?.name ?? placeholder ?? t("workflows:selectWorkflow")}
</span>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<div className="flex min-w-0 items-center gap-2" data-testid="workflow-selector-row">
<WorkflowSelectorTrigger selectedWorkflow={selectedWorkflow} placeholder={placeholder} />
{launchPreview && (
<>
<LaunchDestinationInfo />
<LaunchDestinationLabel stepName={launchPreview.stepName} />
</>
)}
</div>
<PopoverContent className="w-auto min-w-[300px] max-w-none p-1" align="start">
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
{t("workflows:workflow")}
+102
View File
@@ -197,6 +197,108 @@ test.describe("Task creation", () => {
}
});
test("shows the selected workflow launch prompt preview", async ({
testPage,
apiClient,
seedData,
}) => {
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Launch Preview Workflow",
);
const configuredStart = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, {
is_start_step: true,
});
const autoStart = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1);
await apiClient.updateWorkflowStep(configuredStart.id, { events: {} });
await apiClient.updateWorkflowStep(autoStart.id, {
prompt: "Launch instructions: {{task_prompt}} | {{task_prompt}} | {task_id} | @saved",
events: { on_enter: [{ type: "auto_start_agent" }] },
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
task_create_last_used: {
repository_id: seedData.repositoryId,
branch: "main",
agent_profile_id: seedData.agentProfileId,
workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId },
},
});
try {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
const workflowSelector = dialog.getByTestId("workflow-selector-trigger");
await expect(workflowSelector).toContainText("E2E Workflow");
await workflowSelector.click();
await testPage
.getByRole("button", { name: /^Launch Preview Workflow/ })
.last()
.click();
await expect(workflowSelector).toContainText("Launch Preview Workflow");
await dialog.getByTestId("task-description-input").fill("");
const launchStep = dialog.getByTestId("task-create-launch-step");
await expect(launchStep).toHaveText("Start step: Backlog");
await expect(workflowSelector).not.toContainText("Start step:");
const selectorBox = await workflowSelector.boundingBox();
const launchStepBox = await launchStep.boundingBox();
const launchStepInfo = dialog.getByTestId("task-create-launch-step-info");
await expect(launchStepInfo).toHaveAttribute("aria-label", "Learn about the task start step");
const launchStepInfoBox = await launchStepInfo.boundingBox();
if (!selectorBox || !launchStepInfoBox || !launchStepBox) {
throw new Error("launch step controls have no layout box");
}
expect(launchStepInfoBox.x).toBeGreaterThanOrEqual(selectorBox.x + selectorBox.width - 1);
expect(launchStepBox.x).toBeGreaterThanOrEqual(
launchStepInfoBox.x + launchStepInfoBox.width - 1,
);
await launchStepInfo.hover();
await expect(
testPage.getByRole("tooltip", {
name: "The task starts in this workflow step. With a task description, an auto-start step can take priority over the configured Start step.",
}),
).toBeVisible();
await dialog.getByTestId("task-title-input").fill("Preview the launch prompt");
const description = "Review the launch preview";
await dialog.getByTestId("task-description-input").fill(description);
await expect(launchStep).toHaveText("Start step: In Progress");
const toggle = dialog.getByTestId("task-create-launch-preview-toggle");
await expect(toggle).toHaveAttribute(
"aria-label",
"Preview launch prompt with workflow step prompt: In Progress",
);
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await toggle.hover();
await expect(
testPage.getByRole("tooltip", {
name: "Preview launch prompt with workflow step prompt: In Progress",
}),
).toBeVisible();
await toggle.click();
await expect(dialog.getByTestId("task-create-launch-preview-content")).toContainText(
`Launch instructions: ${description} | {{task_prompt}} | {task_id} | @saved`,
);
await expect(dialog.getByTestId("task-description-input")).toHaveCount(0);
await toggle.click();
await expect(dialog.getByTestId("task-description-input")).toHaveValue(description);
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).not.toBeVisible();
} finally {
await apiClient.deleteWorkflow(workflow.id).catch(() => {});
}
});
test("keeps remembered workflows isolated by workspace after reload", async ({
testPage,
apiClient,
@@ -0,0 +1,115 @@
import { expect, test } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
useRegularMode();
test.describe("mobile task-create launch prompt preview", () => {
test("keeps the launch preview inside the touch dialog", async ({
testPage,
apiClient,
seedData,
}) => {
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Mobile Launch Preview Workflow",
);
const configuredStart = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0, {
is_start_step: true,
});
const autoStart = await apiClient.createWorkflowStep(workflow.id, "In Progress", 1);
await apiClient.updateWorkflowStep(configuredStart.id, { events: {} });
await apiClient.updateWorkflowStep(autoStart.id, {
prompt: "Mobile launch: {{task_prompt}} | {{task_prompt}} | {task_id}",
events: { on_enter: [{ type: "auto_start_agent" }] },
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
task_create_last_used: {
repository_id: seedData.repositoryId,
branch: "main",
agent_profile_id: seedData.agentProfileId,
workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId },
},
});
try {
await testPage.setViewportSize({ width: 390, height: 844 });
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileFab.tap();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
const workflowSelector = dialog.getByTestId("workflow-selector-trigger");
await workflowSelector.tap();
await testPage
.getByRole("button", { name: /^Mobile Launch Preview Workflow/ })
.last()
.tap();
await dialog.getByTestId("task-description-input").fill("");
const launchStep = dialog.getByTestId("task-create-launch-step");
await expect(launchStep).toHaveText("Start step: Backlog");
await expect(workflowSelector).not.toContainText("Start step:");
const selectorBox = await workflowSelector.boundingBox();
const launchStepBox = await launchStep.boundingBox();
const launchStepInfo = dialog.getByTestId("task-create-launch-step-info");
await expect(launchStepInfo).toHaveAttribute("aria-label", "Learn about the task start step");
const launchStepInfoBox = await launchStepInfo.boundingBox();
if (!selectorBox || !launchStepInfoBox || !launchStepBox) {
throw new Error("launch step controls have no layout box");
}
expect(launchStepInfoBox.x).toBeGreaterThanOrEqual(selectorBox.x + selectorBox.width - 1);
expect(launchStepBox.x).toBeGreaterThanOrEqual(
launchStepInfoBox.x + launchStepInfoBox.width - 1,
);
expect(launchStepInfoBox.width).toBeGreaterThanOrEqual(44);
await dialog.getByTestId("task-title-input").fill("Mobile launch preview");
const description = "Review mobile launch preview";
await dialog.getByTestId("task-description-input").fill(description);
await expect(launchStep).toHaveText("Start step: In Progress");
const toggle = dialog.getByTestId("task-create-launch-preview-toggle");
await expect(toggle).toHaveAttribute(
"aria-label",
"Preview launch prompt with workflow step prompt: In Progress",
);
await expect(toggle).toHaveAttribute("aria-pressed", "false");
const toggleBox = await toggle.boundingBox();
if (!toggleBox) throw new Error("launch preview toggle has no layout box");
expect(toggleBox.width).toBeGreaterThanOrEqual(44);
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
await toggle.tap();
const preview = dialog.getByTestId("task-create-launch-preview-content");
await expect(preview).toContainText(
`Mobile launch: ${description} | {{task_prompt}} | {task_id}`,
);
await expect(dialog.getByTestId("task-description-input")).toHaveCount(0);
const dialogBox = await dialog.boundingBox();
const previewBox = await preview.boundingBox();
if (!dialogBox || !previewBox) throw new Error("launch preview has no layout box");
expect(previewBox.x).toBeGreaterThanOrEqual(dialogBox.x);
expect(previewBox.x + previewBox.width).toBeLessThanOrEqual(
dialogBox.x + dialogBox.width + 1,
);
await toggle.tap();
await expect(dialog.getByTestId("task-description-input")).toHaveValue(description);
await expect(toggle).toHaveAttribute("aria-pressed", "false");
const pageWidth = await testPage.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
}));
expect(pageWidth.scroll).toBeLessThanOrEqual(pageWidth.client);
await dialog.getByRole("button", { name: "Cancel", exact: true }).tap();
await expect(dialog).not.toBeVisible();
} finally {
await apiClient.deleteWorkflow(workflow.id).catch(() => {});
}
});
});
+105 -7
View File
@@ -6,11 +6,17 @@ import { registerWorkflowsHandlers } from "./workflows";
type WorkflowItem = { id: string; workspaceId: string; name: string; hidden?: boolean };
const MESSAGE_TIMESTAMP = "2026-01-01T00:00:00Z";
const REVIEW_STEP_ID = "step-1";
const REVIEW_STEP_NAME = "Review";
const REVIEW_STEP_COLOR = "bg-blue-500";
function makeStore(items: WorkflowItem[], activeId: string | null) {
let state = {
workflows: { items, activeId },
workspaces: { activeId: "ws-1" },
kanban: { workflowId: null, steps: [], tasks: [] },
kanbanMulti: { snapshots: {}, isLoading: false },
} as unknown as AppState;
return {
@@ -31,7 +37,7 @@ function updatedMessage(payload: WorkflowPayload): BackendMessageMap["workflow.u
type: "notification",
action: "workflow.updated",
payload,
timestamp: "2026-01-01T00:00:00Z",
timestamp: MESSAGE_TIMESTAMP,
};
}
@@ -41,7 +47,7 @@ function createdMessage(payload: WorkflowPayload): BackendMessageMap["workflow.c
type: "notification",
action: "workflow.created",
payload,
timestamp: "2026-01-01T00:00:00Z",
timestamp: MESSAGE_TIMESTAMP,
};
}
@@ -53,7 +59,31 @@ function stepUpdatedMessage(
type: "notification",
action: "workflow.step.updated",
payload: { step },
timestamp: "2026-01-01T00:00:00Z",
timestamp: MESSAGE_TIMESTAMP,
};
}
function stepCreatedMessage(
step: BackendMessageMap["workflow.step.created"]["payload"]["step"],
): BackendMessageMap["workflow.step.created"] {
return {
id: "msg-1",
type: "notification",
action: "workflow.step.created",
payload: { step },
timestamp: MESSAGE_TIMESTAMP,
};
}
function stepDeletedMessage(
step: BackendMessageMap["workflow.step.deleted"]["payload"]["step"],
): BackendMessageMap["workflow.step.deleted"] {
return {
id: "msg-1",
type: "notification",
action: "workflow.step.deleted",
payload: { step },
timestamp: MESSAGE_TIMESTAMP,
};
}
@@ -183,7 +213,9 @@ describe("workflow step handlers", () => {
...store.getState(),
kanban: {
workflowId: "wf-1",
steps: [{ id: "step-1", title: "Review", color: "bg-blue-500", position: 1 }],
steps: [
{ id: REVIEW_STEP_ID, title: REVIEW_STEP_NAME, color: REVIEW_STEP_COLOR, position: 1 },
],
tasks: [],
},
} as AppState);
@@ -191,12 +223,12 @@ describe("workflow step handlers", () => {
handlers["workflow.step.updated"]?.(
stepUpdatedMessage({
id: "step-1",
id: REVIEW_STEP_ID,
workflow_id: "wf-1",
name: "Review",
name: REVIEW_STEP_NAME,
state: "",
position: 1,
color: "bg-blue-500",
color: REVIEW_STEP_COLOR,
wip_limit: 2,
pull_from_step_id: "step-0",
}),
@@ -207,4 +239,70 @@ describe("workflow step handlers", () => {
pull_from_step_id: "step-0",
});
});
it("keeps cached workflow snapshots in sync for step changes", () => {
const store = makeStore([{ id: "wf-1", workspaceId: "ws-1", name: "Workflow" }], null);
const initialStep = {
id: REVIEW_STEP_ID,
title: REVIEW_STEP_NAME,
color: REVIEW_STEP_COLOR,
position: 1,
prompt: "Review the task",
};
store.setState({
...store.getState(),
kanbanMulti: {
snapshots: {
"wf-1": {
workflowId: "wf-1",
workflowName: "Workflow",
steps: [initialStep],
tasks: [],
},
},
isLoading: false,
},
} as AppState);
const handlers = registerWorkflowsHandlers(store);
handlers["workflow.step.created"]?.(
stepCreatedMessage({
id: "step-2",
workflow_id: "wf-1",
name: "Done",
state: "done",
position: 2,
color: "bg-green-500",
}),
);
handlers["workflow.step.updated"]?.(
stepUpdatedMessage({
id: REVIEW_STEP_ID,
workflow_id: "wf-1",
name: REVIEW_STEP_NAME,
state: "review",
position: 1,
color: REVIEW_STEP_COLOR,
prompt: "Review the task carefully",
}),
);
handlers["workflow.step.deleted"]?.(
stepDeletedMessage({
id: "step-2",
workflow_id: "wf-1",
name: "Done",
state: "done",
position: 2,
color: "bg-green-500",
}),
);
const snapshotSteps = store.getState().kanbanMulti.snapshots["wf-1"]?.steps;
expect(snapshotSteps?.map((step) => step.id)).toEqual([REVIEW_STEP_ID]);
expect(snapshotSteps?.[0]).toMatchObject({
title: REVIEW_STEP_NAME,
position: 1,
prompt: "Review the task carefully",
});
});
});
+50 -14
View File
@@ -1,5 +1,6 @@
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import type { WorkflowPayload } from "@/lib/types/backend";
import {
@@ -7,6 +8,8 @@ import {
normalizeWorkflowProfileSessionEndPolicy,
} from "@/lib/types/http";
type KanbanStep = KanbanState["steps"][number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function stepFromPayload(step: any) {
return {
@@ -32,6 +35,36 @@ function stepFromPayload(step: any) {
};
}
function applyWorkflowStepChange(
state: AppState,
workflowId: string,
update: (steps: KanbanStep[]) => KanbanStep[],
): AppState {
const nextKanbanSteps =
state.kanban.workflowId === workflowId ? update(state.kanban.steps) : state.kanban.steps;
const snapshot = state.kanbanMulti.snapshots[workflowId];
const nextSnapshotSteps = snapshot ? update(snapshot.steps) : undefined;
const kanbanChanged = nextKanbanSteps !== state.kanban.steps;
const snapshotChanged = snapshot !== undefined && nextSnapshotSteps !== snapshot.steps;
if (!kanbanChanged && !snapshotChanged) return state;
return {
...state,
kanban: kanbanChanged ? { ...state.kanban, steps: nextKanbanSteps } : state.kanban,
kanbanMulti:
snapshotChanged && snapshot && nextSnapshotSteps
? {
...state.kanbanMulti,
snapshots: {
...state.kanbanMulti.snapshots,
[workflowId]: { ...snapshot, steps: nextSnapshotSteps },
},
}
: state.kanbanMulti,
};
}
function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppState {
if (state.workspaces.activeId !== payload.workspace_id) return state;
if (state.workflows.items.some((item) => item.id === payload.id)) return state;
@@ -117,31 +150,34 @@ export function registerWorkflowsHandlers(store: StoreApi<AppState>): WsHandlers
},
"workflow.step.created": (message) => {
const step = message.payload.step;
const mappedStep = stepFromPayload(step);
store.setState((state) => {
if (state.kanban.workflowId !== step.workflow_id) return state;
if (state.kanban.steps.some((s) => s.id === step.id)) return state;
const steps = [...state.kanban.steps, stepFromPayload(step)].sort(
(a, b) => a.position - b.position,
);
return { ...state, kanban: { ...state.kanban, steps } };
return applyWorkflowStepChange(state, step.workflow_id, (steps) => {
if (steps.some((s) => s.id === step.id)) return steps;
return [...steps, mappedStep].sort((a, b) => a.position - b.position);
});
});
},
"workflow.step.updated": (message) => {
const step = message.payload.step;
const mappedStep = stepFromPayload(step);
store.setState((state) => {
if (state.kanban.workflowId !== step.workflow_id) return state;
const steps = state.kanban.steps
.map((s) => (s.id === step.id ? stepFromPayload(step) : s))
.sort((a, b) => a.position - b.position);
return { ...state, kanban: { ...state.kanban, steps } };
return applyWorkflowStepChange(state, step.workflow_id, (steps) => {
const index = steps.findIndex((s) => s.id === step.id);
if (index < 0) return steps;
const nextSteps = [...steps];
nextSteps[index] = mappedStep;
return nextSteps.sort((a, b) => a.position - b.position);
});
});
},
"workflow.step.deleted": (message) => {
const step = message.payload.step;
store.setState((state) => {
if (state.kanban.workflowId !== step.workflow_id) return state;
const steps = state.kanban.steps.filter((s) => s.id !== step.id);
return { ...state, kanban: { ...state.kanban, steps } };
return applyWorkflowStepChange(state, step.workflow_id, (steps) => {
if (!steps.some((s) => s.id === step.id)) return steps;
return steps.filter((s) => s.id !== step.id);
});
});
},
};
+6
View File
@@ -480,6 +480,7 @@
"editMarkdownComment": "Edit markdown comment",
"editSummaryLines_one": "{{summary}} ({{count}} line)",
"editSummaryLines_other": "{{summary}} ({{count}} lines)",
"editTaskPrompt": "Edit task prompt",
"editTheMessageForTheMost": "Edit the message for the most recent commit.",
"empty": "(empty)",
"emptyGroup": "Empty group",
@@ -1992,12 +1993,16 @@
"workspaceRoot": "Workspace root",
"worktreeExecutorRequiresRepository": "Worktree executor requires a repository.",
"worktreeNumbered": "Worktree {{index}}",
"launchDestination": "Start step: {{step}}",
"launchDestinationHelpLabel": "Learn about the task start step",
"launchDestinationHelp": "The task starts in this workflow step. With a task description, an auto-start step can take priority over the configured Start step.",
"launchErrorBaseBranchMissing": "The task could not start because its base branch is unavailable.",
"launchErrorDefaultBranchUnresolved": "The task could not resolve the repository default branch.",
"launchErrorPrAlreadyClosed": "The task is linked to a closed pull request.",
"launchErrorGeneric": "The task could not start.",
"launchErrorSessionPreview": "The task launch needs attention.",
"launchNeedsAttention": "Launch needs attention",
"launchPromptPreview": "Launch prompt preview",
"retryWithDefaultBranch": "Retry with default branch",
"pickBaseBranch": "Pick a base branch",
"markReviewDone": "Mark review done",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "Recovery could not start",
"tryRecoveryAgain": "Try the recovery action again.",
"launchFailedSeeDetails": "The task launch failed. Open the task details for recovery actions.",
"previewLaunchPrompt": "Preview launch prompt with workflow step prompt: {{step}}",
"branchPoliciesGroup": "Branch policies",
"branchesGroup": "Branches",
"branchPolicyMarker": "Policy",
+6
View File
@@ -480,6 +480,7 @@
"editMarkdownComment": "Ēďĩţ ḿàŕķďōŵń ćōḿḿēńţ",
"editSummaryLines_one": "{{summary}} ({{count}} ĺĩńē)",
"editSummaryLines_other": "{{summary}} ({{count}} ĺĩńēś)",
"editTaskPrompt": "Ēďĩţ ţàśķ ƥŕōḿƥţ",
"editTheMessageForTheMost": "Ēďĩţ ţĥē ḿēśśàĝē ƒōŕ ţĥē ḿōśţ ŕēćēńţ ćōḿḿĩţ.",
"empty": "(ēḿƥţŷ)",
"emptyGroup": "Ēḿƥţŷ ĝŕōũƥ",
@@ -1992,12 +1993,16 @@
"workspaceRoot": "Ŵōŕķśƥàćē ŕōōţ",
"worktreeExecutorRequiresRepository": "Ŵōŕķţŕēē ēxēćũţōŕ ŕēqũĩŕēś à ŕēƥōśĩţōŕŷ.",
"worktreeNumbered": "Ŵōŕķţŕēē {{index}}",
"launchDestination": "Śţàŕţ śţēƥ: {{step}}",
"launchDestinationHelpLabel": "Ĺēàŕń àƀōũţ ţĥē ţàśķ śţàŕţ śţēƥ",
"launchDestinationHelp": "Ţĥē ţàśķ śţàŕţś ĩń ţĥĩś ŵōŕķƒĺōŵ śţēƥ. Ŵĩţĥ à ţàśķ ďēśćŕĩƥţĩōń, àń àũţō-śţàŕţ śţēƥ ćàń ţàķē ƥŕĩōŕĩţŷ ōvēŕ ţĥē ćōńƒĩĝũŕēď Śţàŕţ śţēƥ.",
"launchErrorBaseBranchMissing": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ ƀēćàũśē ĩţś ƀàśē ƀŕàńćĥ ĩś ũńàvàĩĺàƀĺē.",
"launchErrorDefaultBranchUnresolved": "Ţĥē ţàśķ ćōũĺď ńōţ ŕēśōĺvē ţĥē ŕēƥōśĩţōŕŷ ďēƒàũĺţ ƀŕàńćĥ.",
"launchErrorPrAlreadyClosed": "Ţĥē ţàśķ ĩś ĺĩńķēď ţō à ćĺōśēď ƥũĺĺ ŕēqũēśţ.",
"launchErrorGeneric": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ.",
"launchErrorSessionPreview": "Ţĥē ţàśķ ĺàũńćĥ ńēēďś àţţēńţĩōń.",
"launchNeedsAttention": "Ĺàũńćĥ ńēēďś àţţēńţĩōń",
"launchPromptPreview": "Ĺàũńćĥ ƥŕōḿƥţ ƥŕēvĩēŵ",
"retryWithDefaultBranch": "Ŕēţŕŷ ŵĩţĥ ďēƒàũĺţ ƀŕàńćĥ",
"pickBaseBranch": "Ƥĩćķ à ƀàśē ƀŕàńćĥ",
"markReviewDone": "Ḿàŕķ ŕēvĩēŵ ďōńē",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "Ŕēćōvēŕŷ ćōũĺď ńōţ śţàŕţ",
"tryRecoveryAgain": "Ţŕŷ ţĥē ŕēćōvēŕŷ àćţĩōń àĝàĩń.",
"launchFailedSeeDetails": "Ţĥē ţàśķ ĺàũńćĥ ƒàĩĺēď. Ōƥēń ţĥē ţàśķ ďēţàĩĺś ƒōŕ ŕēćōvēŕŷ àćţĩōńś.",
"previewLaunchPrompt": "Ƥŕēvĩēŵ ĺàũńćĥ ƥŕōḿƥţ ŵĩţĥ ŵōŕķƒĺōŵ śţēƥ ƥŕōḿƥţ: {{step}}",
"branchPoliciesGroup": "Ɓŕàńćĥ ƥōĺĩćĩēś",
"branchesGroup": "Ɓŕàńćĥēś",
"branchPolicyMarker": "Ƥōĺĩćŷ",
+6
View File
@@ -461,6 +461,7 @@
"editMarkdownComment": "Editar o comentário em markdown",
"editSummaryLines_one": "{{summary}} ({{count}} linha)",
"editSummaryLines_other": "{{summary}} ({{count}} linhas)",
"editTaskPrompt": "Editar o prompt da tarefa",
"editTheMessageForTheMost": "Edite a mensagem do commit mais recente.",
"empty": "(vazio)",
"emptyGroup": "Grupo vazio",
@@ -1992,12 +1993,16 @@
"promptHistoryPromptLabel": "Prompt {{number}}",
"promptHistoryPromptLabelGeneric": "Prompt",
"promptHistoryEmpty": "Ainda sem prompts.",
"launchDestination": "Passo de início: {{step}}",
"launchDestinationHelpLabel": "Saiba mais sobre o passo de início",
"launchDestinationHelp": "A tarefa começa neste passo do fluxo. Com uma descrição da tarefa, um passo que inicia automaticamente um agente pode ter prioridade sobre o passo de início configurado.",
"launchErrorBaseBranchMissing": "Não foi possível iniciar a tarefa porque o branch base não está disponível.",
"launchErrorDefaultBranchUnresolved": "Não foi possível resolver o branch predefinido do repositório.",
"launchErrorPrAlreadyClosed": "A tarefa está associada a um pull request fechado.",
"launchErrorGeneric": "Não foi possível iniciar a tarefa.",
"launchErrorSessionPreview": "O início da tarefa requer atenção.",
"launchNeedsAttention": "O início requer atenção",
"launchPromptPreview": "Pré-visualização do prompt de início",
"retryWithDefaultBranch": "Tentar novamente com o branch predefinido",
"pickBaseBranch": "Escolher um branch base",
"markReviewDone": "Marcar revisão como concluída",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "Não foi possível iniciar a recuperação",
"tryRecoveryAgain": "Tente novamente a ação de recuperação.",
"launchFailedSeeDetails": "O início da tarefa falhou. Abra os detalhes da tarefa para ver as ações de recuperação.",
"previewLaunchPrompt": "Pré-visualizar o prompt de início com o prompt do passo do fluxo: {{step}}",
"branchPoliciesGroup": "Políticas de branches",
"branchesGroup": "Branches Git",
"branchPolicyMarker": "Política",
+6
View File
@@ -461,6 +461,7 @@
"editMarkdownComment": "编辑 markdown 评论",
"editSummaryLines_one": "{{summary}}({{count}} 行)",
"editSummaryLines_other": "{{summary}}({{count}} 行)",
"editTaskPrompt": "编辑任务提示词",
"editTheMessageForTheMost": "编辑最近一次提交的说明。",
"empty": "(空)",
"emptyGroup": "空分组",
@@ -1992,12 +1993,16 @@
"promptHistoryPromptLabel": "提示 {{number}}",
"promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暂无提示。",
"launchDestination": "开始步骤:{{step}}",
"launchDestinationHelpLabel": "了解任务开始步骤",
"launchDestinationHelp": "任务会在此工作流步骤开始。有任务描述时,自动启动智能体的步骤可能优先于配置的开始步骤。",
"launchErrorBaseBranchMissing": "任务无法启动,因为基础分支不可用。",
"launchErrorDefaultBranchUnresolved": "无法解析仓库的默认分支。",
"launchErrorPrAlreadyClosed": "任务关联的拉取请求已关闭。",
"launchErrorGeneric": "任务无法启动。",
"launchErrorSessionPreview": "任务启动需要处理。",
"launchNeedsAttention": "启动需要处理",
"launchPromptPreview": "启动提示词预览",
"retryWithDefaultBranch": "使用默认分支重试",
"pickBaseBranch": "选择基础分支",
"markReviewDone": "标记评审完成",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "无法开始恢复",
"tryRecoveryAgain": "请再次尝试恢复操作。",
"launchFailedSeeDetails": "任务启动失败。打开任务详情以查看恢复操作。",
"previewLaunchPrompt": "预览工作流步骤提示词的启动提示词:{{step}}",
"branchPoliciesGroup": "分支策略",
"branchesGroup": "分支",
"branchPolicyMarker": "策略",
+6
View File
@@ -461,6 +461,7 @@
"editMarkdownComment": "編輯 markdown 評論",
"editSummaryLines_one": "{{summary}}({{count}} 行)",
"editSummaryLines_other": "{{summary}}({{count}} 行)",
"editTaskPrompt": "編輯任務提示詞",
"editTheMessageForTheMost": "編輯最近一次提交的說明。",
"empty": "(空)",
"emptyGroup": "空分組",
@@ -1992,12 +1993,16 @@
"promptHistoryPromptLabel": "提示 {{number}}",
"promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暫無提示。",
"launchDestination": "開始步驟:{{step}}",
"launchDestinationHelpLabel": "了解任務開始步驟",
"launchDestinationHelp": "任務會在此工作流程步驟開始。有任務描述時,自動啓動代理程式的步驟可能優先於已設定的開始步驟。",
"launchErrorBaseBranchMissing": "任務無法啓動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的拉取要求已關閉。",
"launchErrorGeneric": "任務無法啓動。",
"launchErrorSessionPreview": "任務啓動需要處理。",
"launchNeedsAttention": "啓動需要處理",
"launchPromptPreview": "啓動提示詞預覽",
"retryWithDefaultBranch": "使用預設分支重試",
"pickBaseBranch": "選擇基礎分支",
"markReviewDone": "標記審查完成",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "無法開始恢復",
"tryRecoveryAgain": "請再次嘗試恢復操作。",
"launchFailedSeeDetails": "任務啓動失敗。開啟任務詳情以查看恢復操作。",
"previewLaunchPrompt": "預覽工作流程步驟提示詞的啓動提示詞:{{step}}",
"branchPoliciesGroup": "分支策略",
"branchesGroup": "分支",
"branchPolicyMarker": "策略",
+6
View File
@@ -461,6 +461,7 @@
"editMarkdownComment": "編輯 markdown 評論",
"editSummaryLines_one": "{{summary}}({{count}} 行)",
"editSummaryLines_other": "{{summary}}({{count}} 行)",
"editTaskPrompt": "編輯任務提示詞",
"editTheMessageForTheMost": "編輯最近一次提交的說明。",
"empty": "(空)",
"emptyGroup": "空分組",
@@ -1992,12 +1993,16 @@
"promptHistoryPromptLabel": "提示 {{number}}",
"promptHistoryPromptLabelGeneric": "提示",
"promptHistoryEmpty": "暫無提示。",
"launchDestination": "開始步驟:{{step}}",
"launchDestinationHelpLabel": "瞭解任務開始步驟",
"launchDestinationHelp": "任務會在此工作流程步驟開始。有任務描述時,自動啟動代理程式的步驟可能優先於已設定的開始步驟。",
"launchErrorBaseBranchMissing": "任務無法啟動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的提取要求已關閉。",
"launchErrorGeneric": "任務無法啟動。",
"launchErrorSessionPreview": "任務啟動需要處理。",
"launchNeedsAttention": "啟動需要處理",
"launchPromptPreview": "啟動提示詞預覽",
"retryWithDefaultBranch": "使用預設分支重試",
"pickBaseBranch": "選擇基礎分支",
"markReviewDone": "標記審查完成",
@@ -2007,6 +2012,7 @@
"launchRecoveryFailed": "無法開始恢復",
"tryRecoveryAgain": "請再次嘗試恢復操作。",
"launchFailedSeeDetails": "任務啟動失敗。開啟任務詳情以檢視恢復操作。",
"previewLaunchPrompt": "預覽工作流程步驟提示詞的啟動提示詞:{{step}}",
"branchPoliciesGroup": "分支策略",
"branchesGroup": "分支",
"branchPolicyMarker": "策略",
@@ -0,0 +1,128 @@
---
created: 2026-09-05
status: complete
requirements:
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002
system_design:
- ../../specs/tasks/system-design/task-create-launch-preview.md
legacy_specs: []
---
# Implementation Plan: Task Create Launch Preview
## Overview
Add a shared frontend projection of the backend immediate-launch rules. Use that
projection to show the destination step and to preview its composed step prompt.
The projection must follow the available create action: empty descriptions use
the plan-mode first step, while nonempty descriptions use immediate agent-start
routing.
The pure projection lands first, the UI follows, and responsive E2E tests prove
the complete flow.
## Scope
### In scope
- Show the immediate launch destination beside the selected workflow.
- Preview a nonempty destination step prompt with the current task prompt.
- Preserve the task prompt while the preview is visible.
- Localize the new controls in all supported locales.
- Update the public task creation guide.
- Prove desktop and mobile behavior.
### Out of scope
- Changing launch routing or backend prompt composition.
- Selecting a workflow destination step.
- Expanding task IDs, saved prompts, or runtime-only context before creation.
- Adding the preview outside the primary task creation dialog.
## Technical approach
### Launch projection
Add `task-create-dialog-launch-preview.ts` with pure launch-step and prompt
composition helpers. The step resolver uses the first positional step for the
empty-description plan-mode action, mirrors `ResolveAutoStartStep` for
nonempty descriptions, and filters fetched steps by the effective workflow ID.
Extend `StepType` and `useWorkflowStepsEffect` so fallback-fetched step data
retains `prompt` and workflow identity. Keep loaded workflow snapshots current
from workflow-step WebSocket events, so the visible context does not need a
component-level refresh request. Build one derived launch-preview model in the
dialog prop assembly. Do not store derived preview data in form state.
### Dialog presentation
Extend `WorkflowSelectorRow` with the selected launch destination. Render the
step after the workflow name with muted, truncating text.
Add a focused prompt-preview control to the create composer. Place its icon
after **Enhance prompt with AI**. The control toggles the existing editor between
the unchanged task prompt and a read-only composed preview.
Keep the task dialog as the outer scroll owner. Give the new icon a 44-pixel
coarse-pointer hit area and retain compact desktop density.
### Localization and documentation
Add task-namespace labels for preview mode, edit mode, and the preview surface.
Update all supported locale catalogs, using `i18n:zh-hant` for Traditional
Chinese catalogs.
Update `docs/public/tasks-and-workflows.md`. Explain that the displayed step is
the immediate launch destination and that server-owned values remain unresolved
until task creation.
## Tests
- `AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.2` and `.5` map to resolver and
prop-builder tests for plan-mode, auto-start, configured-start, and positional
fallbacks.
- `AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.3` maps to stale fetched-step and
workflow-switch tests.
- `AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.2` through `.5` map to composition
and component toggle tests.
- `AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.6` maps to accessible-state and
responsive hit-area tests.
## E2E tests
- Extend `apps/web/e2e/tests/task/create-task.spec.ts` with a custom workflow.
Its configured start step differs from its first auto-start step. Assert the
displayed launch destination and composed preview.
- Add
`apps/web/e2e/tests/task/mobile-create-task-launch-preview.spec.ts`. Assert the
same preview flow, a 44-pixel touch target, viewport containment, and no
document-level horizontal overflow in `mobile-chrome`.
## Work orders
- [x] [Task 01: Resolve launch preview data](task-01-resolve-launch-preview-data.md)
- [x] [Task 02: Present launch preview controls](task-02-present-launch-preview-controls.md)
- [x] [Task 03: Prove responsive launch previews](task-03-prove-responsive-launch-previews.md)
## Verification results
- Targeted launch-preview, effects, prop-builder, selector, and workflow
handler tests passed (67 tests after review fixup coverage).
- Task-create component tests passed (43 tests); focused ESLint, typecheck,
i18n checks, pseudo-catalog sync, and public-doc validators passed.
- Desktop and mobile focused E2E tests passed (1 each) in host mode.
- `git diff --check` passed.
## Risks
- The frontend resolver can drift from backend launch routing. Focused fallback
tests must encode the backend precedence.
- A workflow switch can briefly expose the snapshot while a fresh fetch is in
flight. Every fetched step must match the current effective workflow before
use, and a successful empty fetch is authoritative. Loaded snapshots are
updated by workflow-step WebSocket events for visible and non-visible
workflows.
- The preview cannot show server-owned task IDs or saved-prompt expansions.
Public copy must state this boundary without implying full runtime expansion.
- Long workflow and step names can crowd the selector on phones. Truncation and
the mobile browser test must protect the dialog width.
@@ -0,0 +1,106 @@
---
id: "01-resolve-launch-preview-data"
title: "Resolve launch preview data"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002
acceptance_criteria:
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.2
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.3
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.4
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.3
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.4
system_design:
- ../../specs/tasks/system-design/task-create-launch-preview.md
---
# Task 01: Resolve Launch Preview Data
## Summary
Create the shared launch-step and prompt-composition projection. Preserve prompt
data when the dialog fetches steps for a different effective workflow, and keep
the visible workflow's cached steps current through workflow-step events.
## In scope
- Add pure action-sensitive launch destination and composed-preview helpers.
- Retain `prompt` in fetched task-create step data.
- Derive one launch-preview model for the effective workflow.
- Add unit tests for routing precedence, stale data, and prompt substitution.
## Out of scope
- Rendered controls or localized copy.
- Backend routing or prompt changes.
- Browser E2E tests.
## Acceptance
- The resolver matches the backend auto-start, configured-start, and positional
fallback order.
- Fetched steps can contribute only when their workflow ID matches the effective
workflow. A successful empty fetch is authoritative over the snapshot.
- Workflow-step create, update, and delete events keep loaded workflow
snapshots current for the visible and non-visible workflows.
- Composition replaces only the first `{{task_prompt}}` and preserves all
server-owned placeholders.
## Verification
```bash
cd apps/web
pnpm test -- --run components/task-create-dialog-launch-preview.test.ts components/task-create-dialog-effects.test.ts components/task-create-dialog-prop-builders.test.ts
pnpm run typecheck
```
## Files likely touched
- `apps/web/components/task-create-dialog-launch-preview.ts`
- `apps/web/components/task-create-dialog-launch-preview.test.ts`
- `apps/web/components/task-create-dialog-types.ts`
- `apps/web/components/task-create-dialog-effects.ts`
- `apps/web/components/task-create-dialog-effects.test.ts`
- `apps/web/components/task-create-dialog-prop-builders.ts`
- `apps/web/components/task-create-dialog-prop-builders.test.ts`
- `apps/web/lib/ws/handlers/workflows.ts`
- `apps/web/lib/ws/handlers/workflows.test.ts`
## Dependencies
None.
## Risks
- A copied routing rule can drift from `ResolveAutoStartStep`.
- Stale fetched steps can display another workflow's prompt without strict
workflow filtering.
## Parallelism
`sequential`
## Inputs
- Requirement IDs and the launch-step projection section in the system design.
- `workflow.Service.ResolveAutoStartStep` and
`orchestrator.Service.buildWorkflowPromptWithTrustedContext`.
- Existing task-create workflow default and fetch tests.
## Results
- Added the pure launch-step resolver and prompt-composition helper with the
backend's auto-start, configured-start, and positional fallback order.
- Preserved `prompt` and workflow identity in fetched task-create steps, then
derived one effective-workflow launch-preview model in dialog prop assembly.
- Added unit coverage for routing precedence, stale workflow data, and
server-owned prompt placeholders.
- `cd apps/web && pnpm test -- --run components/task-create-dialog-launch-preview.test.ts components/task-create-dialog-effects.test.ts components/task-create-dialog-prop-builders.test.ts` passed (40 tests).
- `cd apps/web && pnpm run typecheck` passed.
- Review fixup coverage passed: plan-mode routing, authoritative empty fetch,
and workflow snapshot step-event synchronization. Disabled-tooltip focus
coverage passed with the task-create component tests.
@@ -0,0 +1,133 @@
---
id: "02-present-launch-preview-controls"
title: "Present launch preview controls"
status: done
wave: 2
depends_on:
- "01-resolve-launch-preview-data"
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002
acceptance_criteria:
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.1
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.3
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.1
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.2
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.5
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.6
system_design:
- ../../specs/tasks/system-design/task-create-launch-preview.md
---
# Task 02: Present Launch Preview Controls
## Summary
Show the launch destination beside, but outside, the workflow selector. Keep
the selector trigger at its intrinsic width and add an inline information
button for launch-step guidance. Add the prompt preview toggle, localization,
component coverage, and public task guidance.
## In scope
- Render an information button immediately after the selected workflow trigger,
followed by the muted **Start step: {{step}}** label.
- Keep the workflow selector trigger independent from the adjacent launch-step
label and information button.
- Add localized launch-step help to the information button with a coarse-pointer
hit area of at least 44 CSS pixels.
- Add the preview icon after **Enhance prompt with AI**.
- Toggle between the unchanged editor and a read-only composed preview.
- Add accessible names, pressed state, and coarse-pointer sizing.
- Update all locale catalogs and the public task creation guide.
## Out of scope
- Launch behavior, API contracts, or persisted state.
- Other prompt composers.
## Acceptance
- The selector and prompt editor render the shared launch-preview model without
adding a second routing rule.
- Toggling the preview preserves the exact task prompt. Losing the preview model
returns the editor to edit mode.
- Localized, accessible controls work at desktop and coarse-pointer sizes. The
selector trigger remains independently clickable, and the adjacent help
button explains the action-sensitive launch destination.
## Verification
```bash
cd apps/web
pnpm test -- --run components/task-create-dialog-selectors.test.tsx components/task-create-dialog-form-body.test.tsx components/workflow-selector-row.test.tsx components/task-create-dialog.test.tsx
pnpm run typecheck
pnpm exec eslint --max-warnings 0 components/task-create-dialog-selectors.tsx components/task-create-dialog-selectors.test.tsx components/task-create-dialog-form-body.tsx components/task-create-dialog-form-body.test.tsx components/workflow-selector-row.tsx components/workflow-selector-row.test.tsx
pnpm run i18n:check
pnpm run i18n:ratchet
cd ../..
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
git diff --check
```
## Files likely touched
- `apps/web/components/task-create-dialog-selectors.tsx`
- `apps/web/components/task-create-dialog-selectors.test.tsx`
- `apps/web/components/task-create-dialog-form-body.tsx`
- `apps/web/components/task-create-dialog-form-body.test.tsx`
- `apps/web/components/workflow-selector-row.tsx`
- `apps/web/components/workflow-selector-row.test.tsx`
- `apps/web/e2e/tests/task/create-task.spec.ts`
- `apps/web/e2e/tests/task/mobile-create-task-launch-preview.spec.ts`
- `apps/web/components/task-create-dialog.test.tsx`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/pt-pt/task.json`
- `apps/web/src/locales/zh-cn/task.json`
- `apps/web/src/locales/zh-hk/task.json`
- `apps/web/src/locales/zh-tw/task.json`
- `docs/public/tasks-and-workflows.md`
## Dependencies
- Task 01 supplies the launch-preview model and composition helper.
## Risks
- The large shared composer can exceed lint limits if preview presentation is
not extracted into a focused component.
- Long prompt content needs bounded wrapping without adding document overflow.
## Parallelism
`sequential`
## Inputs
- The prompt editor and workflow selector sections in the system design.
- Existing task-create composer toolbar and selector component tests.
- The public **Create a task** procedure.
## Results
- Added the muted **Start step: {{step}}** destination beside, but outside, the
workflow selector and an inline preview toggle after **Enhance prompt with AI**.
- Made the preview tooltip identify the workflow step prompt it will show.
- Added the read-only wrapping preview surface, localized accessible labels,
pressed state, and 44-pixel coarse-pointer sizing.
- Added all five locale catalog entries, regenerated the pseudo catalog, and
updated the public task creation guide.
- Wrapped the disabled preview button in the shared focusable tooltip-trigger
pattern so its explanation remains reachable by keyboard and pointer.
- Follow-up refinement moved the launch-step controls outside the selector,
placed the information button immediately after the trigger, changed the
copy to **Start step: {{step}}**, and made the preview tooltip identify its
workflow step prompt.
- Focused component tests passed (43 tests), typecheck passed, and the focused
ESLint command passed with zero warnings.
- `cd apps/web && pnpm run i18n:check` passed; `pnpm run i18n:ratchet` passed.
- `node --test scripts/validate-public-docs.test.mjs` passed (61 tests) and
`node scripts/validate-public-docs.mjs` validated 45 pages.
- `git diff --check` passed.
@@ -0,0 +1,93 @@
---
id: "03-prove-responsive-launch-previews"
title: "Prove responsive launch previews"
status: done
wave: 3
depends_on:
- "02-present-launch-preview-controls"
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002
acceptance_criteria:
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.1
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.2
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.2
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.3
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.6
- AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.7
system_design:
- ../../specs/tasks/system-design/task-create-launch-preview.md
---
# Task 03: Prove Responsive Launch Previews
## Summary
Prove the complete launch destination and prompt preview flow in desktop and
mobile production builds. Use a workflow whose configured start and auto-start
steps differ.
## In scope
- Add a desktop task-create scenario to the existing focused spec.
- Add a mobile task-create launch-preview spec.
- Prove prompt preservation, touch size, viewport containment, and no horizontal
overflow.
## Out of scope
- Broad E2E or regression suites.
- Backend routing tests that already cover `ResolveAutoStartStep`.
- Screenshot or product-video updates.
## Acceptance
- Desktop shows the first positional destination while the description is empty,
then the actual auto-start destination and correctly composed step prompt after
a workflow switch and description entry.
- Mobile completes the same edit, preview, and return-to-edit flow.
- The mobile icon is at least 44 CSS pixels, and the dialog causes no document
horizontal overflow.
## Verification
```bash
cd apps/web
pnpm e2e:run tests/task/create-task.spec.ts -- --grep "launch prompt preview"
pnpm e2e:run --project mobile-chrome tests/task/mobile-create-task-launch-preview.spec.ts
```
## Files likely touched
- `apps/web/e2e/tests/task/create-task.spec.ts`
- `apps/web/e2e/tests/task/mobile-create-task-launch-preview.spec.ts`
## Dependencies
- Task 02 supplies the rendered controls and stable test IDs.
## Risks
- The task-create fixture can retain remembered workflow state. The scenario
must set or clear that state explicitly.
- The mobile project discovers only files whose names start with `mobile-`.
## Parallelism
`sequential`
## Inputs
- The responsive behavior and verification sections in the system design.
- Existing task-create desktop and mobile Playwright fixtures.
## Results
- Added desktop coverage using a workflow whose configured start and
auto-start destinations differ, including workflow switching and prompt
token preservation.
- Added the focused `mobile-create-task-launch-preview.spec.ts` coverage with
touch interactions, geometry, containment, and document overflow assertions.
- `cd apps/web && pnpm e2e:run --host --no-build tests/task/create-task.spec.ts -- --grep "launch prompt preview"` passed (1 test).
- `cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-create-task-launch-preview.spec.ts` passed (1 test).
+2 -2
View File
@@ -68,7 +68,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
task titles** is enabled, the New Task dialog hides this field, requires a nonempty prompt, and uses
the prompt's first six words as a provisional title while the first eligible agent session chooses
the final title. The empty-description Plan Mode exception applies only when this setting is disabled.
2. Select the workspace and workflow when Kandev cannot infer them. A regular non-ephemeral task must belong to a workflow.
2. Select the workspace and workflow when Kandev cannot infer them. A regular non-ephemeral task must belong to a workflow. When the workflow selector shows a selected workflow, **Start step** identifies the step used by the applicable immediate launch action. Select the information button immediately after the workflow selector for an explanation of the action-sensitive destination. With an empty description, **Start Plan Mode** uses the first positional step. After you enter a description, **Start task** and **Start task in plan mode** use the first positional step with **Auto-start agent**, then the configured **Start step**, then the first step.
3. Select a source:
| Source | Use it for | Important behavior |
@@ -78,7 +78,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
| **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. |
4. Select a compatible executor profile and agent profile. A workflow default agent profile locks the task-level agent selector. Executor and agent compatibility is validated before launch.
5. Enter the initial description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode**; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. When agent-generated task titles are enabled, every task and subtask action requires a nonempty prompt; the empty-description Plan Mode exception is disabled. A nonempty description exposes the standard split actions.
5. Enter the initial description. If the applicable launch step has a prompt template, use the eye button after **Enhance prompt with AI** to inspect the launch prompt with your description inserted. The preview leaves task IDs and saved-prompt references unresolved until the task exists. Toggle the button again to return to the unchanged description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode** and uses the first positional workflow step; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. When agent-generated task titles are enabled, every task and subtask action requires a nonempty prompt; the empty-description Plan Mode exception is disabled. A nonempty description exposes the standard split actions and updates **Start step** to the immediate auto-start destination.
6. Choose the applicable action:
- **Start Plan Mode** is the primary empty-description action and creates the task through the plan-mode path.
- **Start task** requires a nonempty description, creates the task, and starts its agent. This path starts in the first positional step whose entry actions include **Auto-start agent**, falling back to **Start step** when the workflow automates no step.
+2
View File
@@ -90,6 +90,7 @@ signals, and task-scoped scheduling contracts.
- [Create Task Escape Dismissal](requirements/task-create-escape-dismissal.md)
- [Task Create Agent Compatibility Recovery](requirements/task-create-agent-executor-compatibility.md)
- [Task Create Executor Default](requirements/task-create-executor-default.md)
- [Task Create Launch Preview](requirements/task-create-launch-preview.md)
- [Task Create Workflow Memory](requirements/task-create-workflow-memory.md)
- [Task-create advanced settings disclosure](requirements/task-dependencies-create-dialog-advanced-settings.md)
- [Task-create dependency selector refinement](requirements/task-dependencies-create-dialog-dependency-selector.md)
@@ -157,6 +158,7 @@ signals, and task-scoped scheduling contracts.
- [Task Dependencies and Auto-Start Chains](system-design/task-dependencies.md)
- [Edit task dependencies](system-design/task-dependency-detail-editing.md)
- [Task Launch Failure Recovery](system-design/task-launch-failure-recovery.md)
- [Task Create Launch Preview](system-design/task-create-launch-preview.md)
- [Task priority visibility](system-design/task-priority-visibility.md)
- [WIP Limits and Visible Overflow Queues](system-design/wip-limit-pull-system.md)
- [Workflow quorum decision recording](system-design/workflow-quorum-decision-recording.md)
@@ -0,0 +1,110 @@
---
status: draft
system: tasks
created: 2026-09-05
owners:
- kandev
---
# Task Create Launch Preview Requirements
## Overview
The task creation dialog shows the workflow step that the applicable agent-start
action will enter. With an empty description, the primary action is **Start Plan
Mode**, which enters the first positional step. Once a description is present,
the immediate agent-start actions use the workflow auto-start precedence. The
dialog can also preview the step prompt after applying the current task prompt.
This preview lets a person review workflow instructions before starting an agent.
The `tasks` system owns this capability because it owns task creation, launch
routing, and workflow-step prompt behavior. The web dialog presents that
task-owned launch decision.
## Terminology
- **Launch destination:** The workflow step that the applicable immediate
agent-start action will enter. An empty description selects the first
positional step for **Start Plan Mode**; a nonempty description follows the
auto-start, configured-start, and positional fallback order.
- **Step prompt:** The prompt template stored on the launch destination.
- **Composed preview:** The step prompt after the dialog applies substitutions
that are available before task creation.
## Requirements
### REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001: Launch destination disclosure
**Intent:** A person can see where an immediate task launch will start before
submitting the task.
**User story:** As a person creating a task, I want to see its launch step, so
that the workflow destination is not hidden.
#### Acceptance criteria
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.1:** When the workflow selector
shows a selected workflow, the system shall show the immediate launch
destination outside the selector, immediately to its right, with an
adjacent localized information control followed by the muted label
**Start step: {{step}}**. The information control shall provide help that
explains the displayed destination and its action-sensitive precedence.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.2:** When the description is empty,
the displayed launch destination shall be the first positional step because
**Start Plan Mode** uses the plan-mode launch path. When the description is
nonempty, the displayed destination shall be the first positional step with
an `auto_start_agent` entry action. If no such step exists, it shall use the
configured start step, then the first positional step.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.3:** When the selected workflow
changes, the displayed destination shall update without using steps from the
previous workflow.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.4:** When the destination is not
available, the system shall omit the destination text. The workflow selector
shall remain usable.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001.5:** When the description changes
between empty and nonempty, the displayed destination shall update to match
the applicable launch path.
### REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002: Step prompt preview
**Intent:** A person can inspect how the launch step changes the task prompt
before starting the agent.
**User story:** As a person creating a task, I want to preview the launch prompt,
so that a step template cannot replace my task prompt without notice.
#### Acceptance criteria
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.1:** When the launch destination has
a nonempty step prompt, the task prompt toolbar shall show a preview button
immediately after **Enhance prompt with AI**.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.2:** When the person selects the
preview button, the editor shall show the composed preview instead of the
editable task prompt. Selecting the button again shall restore the unchanged
task prompt and its editing controls.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.3:** The composed preview shall
replace the first `{{task_prompt}}` token with the current task prompt. When
the token is absent, the step prompt shall be the complete preview.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.4:** The composed preview shall leave
task IDs, saved-prompt references, and other server-owned values unresolved.
The system shall not invent values that do not exist before task creation.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.5:** When the launch destination has
no step prompt, the toolbar shall not show the preview button. If a workflow
change removes the available preview, the editor shall return to edit mode.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.6:** The preview button shall have a
localized accessible name that identifies the workflow step prompt and
resolved step, plus a pressed state. On coarse pointers, its active hit area
shall be at least 44 CSS pixels.
- **AC-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002.7:** On phone layouts, the prompt
preview shall remain inside the dialog and shall not cause document-level
horizontal overflow.
## Out of scope
- Selecting a different initial workflow step.
- Changing backend launch routing or prompt composition.
- Projecting the **Create without starting agent** destination; that action uses
the configured start step and is not an immediate agent start.
- Showing workflow-level instructions that are separate from the step prompt.
- Expanding saved-prompt references or generating a task ID before creation.
- Adding the preview to task chat, session creation, or subtask creation.
@@ -0,0 +1,180 @@
---
status: draft
system: tasks
requirements:
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001
- REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002
---
# Task Create Launch Preview System Design
## Purpose and boundaries
The task system owns immediate-launch routing and workflow-step prompt
composition. The shared task creation dialog presents a pre-creation projection
of those rules. The projection follows the action currently available in the
form: an empty description exposes **Start Plan Mode**, while a nonempty
description exposes immediate agent-start actions.
This design adds no endpoint, persisted state, or runtime prompt behavior. It
uses the workflow data that the dialog already receives from boot state or the
workflow-step API.
## Requirement mapping
| Requirement | Design sections |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-001` | [Launch-step projection](#launch-step-projection), [Workflow selector](#workflow-selector) |
| `REQ-TASKS-TASK-CREATE-LAUNCH-PREVIEW-002` | [Prompt composition](#prompt-composition), [Prompt editor](#prompt-editor), [Responsive behavior](#responsive-behavior) |
## Components and responsibilities
- A pure task-create helper selects the launch destination and composes the
step-prompt preview.
- `useWorkflowStepsEffect` retains each fallback-fetched step prompt with its
actions, position, and workflow identity.
- Workflow-step WebSocket handlers keep loaded workflow snapshots synchronized
for step create, update, and delete events.
- `buildDialogFormBodyProps` selects steps for the effective workflow. It passes
one launch-preview model to the shared create form.
- `WorkflowSelectorRow` shows the launch destination beside the selected
workflow name.
- `TaskFormInputs` owns the local preview toggle because it already owns the
current task prompt and editor state.
- A focused preview control renders the icon, tooltip, accessible state, and
read-only preview surface.
## Data and contracts
The launch-preview model contains the destination step ID, step name, and step
prompt. It is derived data and is not stored in `DialogFormState`.
The resolver accepts steps from one effective workflow. A step supplies these
existing fields:
- `id`
- `title`
- `position`
- `events.on_enter`
- `is_start_step`
- `prompt`
- `workflowId` when the step came from a fallback fetch
The dialog uses matching fetched steps when they exist. A successful empty fetch
is authoritative and produces no destination. If the fetch is unavailable, or
contains only steps from another workflow, the dialog uses the selected workflow
snapshot. A fetched step from another workflow is never a candidate. When the
effective workflow is the visible context workflow, the dialog uses its live
snapshot; workflow-step WebSocket events keep that snapshot current after edits
made elsewhere. A different effective workflow is fetched by the existing
fallback path.
## Launch-step projection
For a nonempty description, the frontend resolver mirrors
`workflow.Service.ResolveAutoStartStep`:
1. Sort the effective workflow steps by `position`.
2. Select the first step whose `on_enter` actions contain
`auto_start_agent`.
3. If none exists, select the first step with `is_start_step`.
4. If none exists, select the first positional step.
For an empty description, the resolver selects the first positional step. This
matches the **Start Plan Mode** path, which does not start an agent immediately.
The auto-start projection describes the **Start task** and **Start task in plan
mode** actions when a description is present. It does not describe **Create
without starting agent**, which uses the configured start step.
## Prompt composition
The preview helper mirrors the step-template part of
`orchestrator.Service.buildWorkflowPromptWithTrustedContext`.
If the step prompt contains `{{task_prompt}}`, the helper replaces its first
occurrence with the current editor value. The backend also replaces only the
first occurrence. If the token is absent, the helper returns the step prompt
without adding the editor value.
The helper does not resolve `{task_id}` because no task exists. It also leaves
`@name` references unchanged. Saved-prompt expansion remains server-owned under
[ADR-2026-09-01](../../../decisions/2026-09-01-server-owned-saved-prompt-expansion.md).
The preview excludes workflow-level instructions and runtime-only system
context. Its purpose is to show how the launch step applies its own template.
## Workflow selector
The selected workflow trigger renders only the workflow name and existing
chevron. The launch destination renders as a sibling group outside the trigger,
immediately to its right. The group contains the information button first and
the muted localized **Start step: {{step}}** label second. The trigger keeps its
intrinsic selector width and does not claim the full row. The information
button exposes localized help on hover and focus. On coarse pointers, the
information button has a hit area of at least 44 CSS pixels. Truncation keeps
the selector row within its available width.
The existing selector visibility rules remain unchanged. A single implicit
workflow can still omit the selector when it has no override information.
## Prompt editor
The preview button appears only in create mode when the launch destination has
a nonempty prompt. It follows **Enhance prompt with AI** in the toolbar.
The editor keeps its draft in the existing local description state. Preview
mode replaces the editable textarea with a read-only, wrapping text surface.
The preview surface uses the same height boundary and overflow ownership as the
textarea.
The button uses `aria-pressed` and localized labels for preview and edit modes.
The inactive preview label identifies the workflow step prompt and resolved
step, for example **Preview launch prompt with workflow step prompt: In
Progress**. If the workflow changes to a destination without a prompt, the
component closes preview mode and shows the unchanged editor value.
## Responsive behavior
Desktop and mobile use the same launch resolver, prompt value, and toggle state.
The existing task creation dialog remains the entry point and scroll owner.
On fine pointers, the preview icon keeps the compact composer density. On coarse
pointers, its active hit area is at least 44 CSS pixels. The read-only text wraps
inside the composer and owns only the same bounded overflow as the textarea.
The nearest mobile exemplar is the current Kanban FAB to task-create dialog
flow. This change adds no drawer, route, fixed control, or safe-area boundary.
## Failure and recovery
If workflow steps are unavailable, the dialog keeps the selected workflow
snapshot as a temporary fallback and the remaining form stays usable. A
successful empty response omits the destination and preview controls rather
than retaining stale snapshot steps. Workflow identity filtering prevents
another workflow's fetched steps from becoming candidates.
If steps arrive after the dialog opens, the derived model updates from those
steps. A workflow change removes any prior preview model before new steps can
replace it. Step create, update, and delete events update loaded workflow
snapshots, so the visible workflow does not require a component-level refresh
request to reflect edits made elsewhere.
## Localization and public documentation
All new labels use the task namespace in every supported locale. The public
task creation guide explains the launch label, preview boundary, and unresolved
server-owned values.
## Verification
- Unit tests cover action-sensitive launch routing, stale workflow filtering,
workflow-snapshot step-event synchronization, and prompt composition.
- Component tests cover selector text, toggle state, draft preservation, and
the no-prompt fallback.
- Desktop Playwright covers workflow switching and composed preview content.
- Mobile Playwright covers the same user value, the 44-pixel hit area, viewport
containment, and horizontal overflow.
## Related decisions
- [Keep Saved-Prompt Expansion Server-Owned](../../../decisions/2026-09-01-server-owned-saved-prompt-expansion.md)