feat(tasks): preview task launch destination and prompt (#3425)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ƥōĺĩćŷ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "策略",
|
||||
|
||||
@@ -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": "策略",
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user