fix(web): prevent hidden workflow task inheritance (#2031)
This commit is contained in:
@@ -1,13 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSingleWorkflowFallbackId } from "./task-create-dialog-defaults";
|
||||
import * as taskCreateDefaults from "./task-create-dialog-defaults";
|
||||
|
||||
const { computeDialogDefaultStepId, computeSingleWorkflowFallbackId } = taskCreateDefaults;
|
||||
const HIDDEN_WORKFLOW_ID = "improve-kandev";
|
||||
const VISIBLE_WORKFLOW_ID = "visible";
|
||||
const VISIBLE_START_STEP_ID = "visible-start";
|
||||
|
||||
type WorkflowContextResolver = (
|
||||
workflowId: string | null,
|
||||
defaultStepId: string | null,
|
||||
workflows: Array<{ id: string; hidden?: boolean }>,
|
||||
allowHiddenWorkflow: boolean,
|
||||
) => { workflowId: string | null; defaultStepId: string | null };
|
||||
|
||||
describe("computeSingleWorkflowFallbackId", () => {
|
||||
it("selects the sole visible workflow when hidden workflows are also loaded", () => {
|
||||
const workflowId = computeSingleWorkflowFallbackId(null, null, [
|
||||
{ id: "kanban", hidden: false },
|
||||
{ id: "improve-kandev", hidden: true },
|
||||
{ id: HIDDEN_WORKFLOW_ID, hidden: true },
|
||||
]);
|
||||
|
||||
expect(workflowId).toBe("kanban");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskCreateWorkflowContext", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "drops an unlocked hidden workflow inherited from task context",
|
||||
workflowId: HIDDEN_WORKFLOW_ID,
|
||||
defaultStepId: "improve",
|
||||
allowHiddenWorkflow: false,
|
||||
expected: { workflowId: null, defaultStepId: null },
|
||||
},
|
||||
{
|
||||
name: "preserves a locked hidden workflow from a feature wrapper",
|
||||
workflowId: HIDDEN_WORKFLOW_ID,
|
||||
defaultStepId: "improve",
|
||||
allowHiddenWorkflow: true,
|
||||
expected: { workflowId: HIDDEN_WORKFLOW_ID, defaultStepId: "improve" },
|
||||
},
|
||||
{
|
||||
name: "preserves an ordinary visible workflow",
|
||||
workflowId: "kanban",
|
||||
defaultStepId: "backlog",
|
||||
allowHiddenWorkflow: false,
|
||||
expected: { workflowId: "kanban", defaultStepId: "backlog" },
|
||||
},
|
||||
{
|
||||
name: "drops unlocked workflow context whose metadata cannot be resolved",
|
||||
workflowId: "not-yet-loaded",
|
||||
defaultStepId: "start",
|
||||
allowHiddenWorkflow: false,
|
||||
expected: { workflowId: null, defaultStepId: null },
|
||||
},
|
||||
])("$name", ({ workflowId, defaultStepId, allowHiddenWorkflow, expected }) => {
|
||||
const resolver = (
|
||||
taskCreateDefaults as typeof taskCreateDefaults & {
|
||||
resolveTaskCreateWorkflowContext?: WorkflowContextResolver;
|
||||
}
|
||||
).resolveTaskCreateWorkflowContext;
|
||||
|
||||
expect(resolver).toBeTypeOf("function");
|
||||
if (!resolver) return;
|
||||
|
||||
expect(
|
||||
resolver(
|
||||
workflowId,
|
||||
defaultStepId,
|
||||
[
|
||||
{ id: "kanban", hidden: false },
|
||||
{ id: HIDDEN_WORKFLOW_ID, hidden: true },
|
||||
],
|
||||
allowHiddenWorkflow,
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDialogDefaultStepId", () => {
|
||||
it("uses the fetched start step when visible fallback replaces hidden context", () => {
|
||||
expect(
|
||||
computeDialogDefaultStepId({
|
||||
selectedWorkflowId: null,
|
||||
workflowId: null,
|
||||
fetchedSteps: [
|
||||
{
|
||||
id: "visible-backlog",
|
||||
title: "Backlog",
|
||||
workflowId: VISIBLE_WORKFLOW_ID,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: VISIBLE_START_STEP_ID,
|
||||
title: "In Progress",
|
||||
workflowId: VISIBLE_WORKFLOW_ID,
|
||||
position: 1,
|
||||
is_start_step: true,
|
||||
},
|
||||
],
|
||||
defaultStepId: null,
|
||||
effectiveWorkflowId: VISIBLE_WORKFLOW_ID,
|
||||
snapshots: {},
|
||||
}),
|
||||
).toBe(VISIBLE_START_STEP_ID);
|
||||
});
|
||||
|
||||
it("ignores fetched steps from a previously selected workflow", () => {
|
||||
expect(
|
||||
computeDialogDefaultStepId({
|
||||
selectedWorkflowId: VISIBLE_WORKFLOW_ID,
|
||||
workflowId: null,
|
||||
fetchedSteps: [
|
||||
{
|
||||
id: "previous-start",
|
||||
title: "Previous start",
|
||||
is_start_step: true,
|
||||
workflowId: "previous",
|
||||
},
|
||||
],
|
||||
defaultStepId: null,
|
||||
effectiveWorkflowId: VISIBLE_WORKFLOW_ID,
|
||||
snapshots: {
|
||||
[VISIBLE_WORKFLOW_ID]: {
|
||||
workflowId: VISIBLE_WORKFLOW_ID,
|
||||
workflowName: "Visible",
|
||||
steps: [
|
||||
{
|
||||
id: VISIBLE_START_STEP_ID,
|
||||
title: "Visible start",
|
||||
color: "green",
|
||||
position: 0,
|
||||
is_start_step: true,
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(VISIBLE_START_STEP_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskCreateWorkflowContext visibility defaults", () => {
|
||||
it("treats a workflow with omitted hidden metadata as visible", () => {
|
||||
const resolver = (
|
||||
taskCreateDefaults as typeof taskCreateDefaults & {
|
||||
resolveTaskCreateWorkflowContext?: WorkflowContextResolver;
|
||||
}
|
||||
).resolveTaskCreateWorkflowContext;
|
||||
|
||||
expect(resolver).toBeTypeOf("function");
|
||||
if (!resolver) return;
|
||||
|
||||
expect(resolver("legacy", "start", [{ id: "legacy" }], false)).toEqual({
|
||||
workflowId: "legacy",
|
||||
defaultStepId: "start",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { DialogComputedArgs, StepType } from "@/components/task-create-dialog-types";
|
||||
import { computeEffectiveStepId } from "@/components/task-create-dialog-helpers";
|
||||
|
||||
export function resolveTaskCreateWorkflowContext(
|
||||
workflowId: string | null,
|
||||
defaultStepId: string | null,
|
||||
workflows: Array<{ id: string; hidden?: boolean }>,
|
||||
allowHiddenWorkflow: boolean,
|
||||
): { workflowId: string | null; defaultStepId: string | null } {
|
||||
const workflow = workflows.find((item) => item.id === workflowId);
|
||||
if (allowHiddenWorkflow || !workflowId || (workflow && !workflow.hidden)) {
|
||||
return { workflowId, defaultStepId };
|
||||
}
|
||||
return { workflowId: null, defaultStepId: null };
|
||||
}
|
||||
|
||||
export function computeSingleWorkflowFallbackId(
|
||||
selectedWorkflowId: string | null,
|
||||
workflowId: string | null,
|
||||
@@ -39,11 +52,31 @@ export function computeDialogDefaultStepId({
|
||||
effectiveWorkflowId,
|
||||
snapshots,
|
||||
}: ComputeDialogDefaultStepIdArgs): string | null {
|
||||
const matchingFetchedSteps = fetchedSteps?.filter(
|
||||
(step) => step.workflowId === effectiveWorkflowId,
|
||||
);
|
||||
if (effectiveWorkflowId && effectiveWorkflowId !== workflowId) {
|
||||
const fetchedStartStep = matchingFetchedSteps?.find((step) => step.is_start_step);
|
||||
const fetchedFirstStep = matchingFetchedSteps
|
||||
? [...matchingFetchedSteps].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))[0]
|
||||
: null;
|
||||
return (
|
||||
fetchedStartStep?.id ??
|
||||
fetchedFirstStep?.id ??
|
||||
computeSnapshotDefaultStepId(effectiveWorkflowId, snapshots)
|
||||
);
|
||||
}
|
||||
|
||||
const switchedWorkflowWithoutFetchedSteps =
|
||||
Boolean(selectedWorkflowId) && selectedWorkflowId !== workflowId && !fetchedSteps;
|
||||
Boolean(selectedWorkflowId) && selectedWorkflowId !== workflowId && !matchingFetchedSteps;
|
||||
const computedStepId = switchedWorkflowWithoutFetchedSteps
|
||||
? null
|
||||
: computeEffectiveStepId(selectedWorkflowId, workflowId, fetchedSteps, defaultStepId);
|
||||
: computeEffectiveStepId(
|
||||
selectedWorkflowId,
|
||||
workflowId,
|
||||
matchingFetchedSteps ?? null,
|
||||
defaultStepId,
|
||||
);
|
||||
|
||||
return computedStepId ?? computeSnapshotDefaultStepId(effectiveWorkflowId, snapshots);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,20 @@ import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
useDefaultSelectionsEffect,
|
||||
useWorkflowAgentProfileEffect,
|
||||
useWorkflowStepsEffect,
|
||||
} from "./task-create-dialog-effects";
|
||||
import { decideAgentProfileAutopick } from "./task-create-dialog-autopick";
|
||||
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import type { Workspace } from "@/lib/types/http";
|
||||
const STORAGE_KEYS = { LAST_AGENT_PROFILE_ID: "kandev.dialog.lastAgentProfileId" } as const;
|
||||
const workflowApiMocks = vi.hoisted(() => ({
|
||||
listWorkflowSteps: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/workflow-api", () => ({
|
||||
listWorkflowSteps: workflowApiMocks.listWorkflowSteps,
|
||||
}));
|
||||
|
||||
// Minimal fake of DialogFormState - the hook destructures only three fields,
|
||||
// so the rest can be undefined behind an `as` cast and never read.
|
||||
@@ -45,6 +53,85 @@ function makeProfile(id: string): AgentProfileOption {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
workflowApiMocks.listWorkflowSteps.mockReset();
|
||||
});
|
||||
|
||||
describe("useWorkflowStepsEffect", () => {
|
||||
it("loads the visible fallback steps when hidden task context was removed", async () => {
|
||||
workflowApiMocks.listWorkflowSteps.mockResolvedValue({
|
||||
steps: [
|
||||
{
|
||||
id: "visible-backlog",
|
||||
workflow_id: "visible",
|
||||
name: "Backlog",
|
||||
position: 0,
|
||||
is_start_step: false,
|
||||
},
|
||||
{
|
||||
id: "visible-start",
|
||||
workflow_id: "visible",
|
||||
name: "In Progress",
|
||||
position: 1,
|
||||
is_start_step: true,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
const setFetchedSteps = vi.fn();
|
||||
const fs = {
|
||||
selectedWorkflowId: null,
|
||||
setFetchedSteps,
|
||||
} as unknown as DialogFormState;
|
||||
|
||||
renderHook(() => useWorkflowStepsEffect(fs, true, null, "visible"));
|
||||
|
||||
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledWith("visible"));
|
||||
await waitFor(() =>
|
||||
expect(setFetchedSteps).toHaveBeenCalledWith([
|
||||
{
|
||||
id: "visible-backlog",
|
||||
title: "Backlog",
|
||||
position: 0,
|
||||
is_start_step: false,
|
||||
events: undefined,
|
||||
workflowId: "visible",
|
||||
},
|
||||
{
|
||||
id: "visible-start",
|
||||
title: "In Progress",
|
||||
position: 1,
|
||||
is_start_step: true,
|
||||
events: undefined,
|
||||
workflowId: "visible",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears stale steps and retries the visible fallback when the dialog reopens", async () => {
|
||||
workflowApiMocks.listWorkflowSteps.mockRejectedValueOnce(new Error("temporary failure"));
|
||||
workflowApiMocks.listWorkflowSteps.mockResolvedValueOnce({ steps: [], total: 0 });
|
||||
const setFetchedSteps = vi.fn();
|
||||
const fs = {
|
||||
selectedWorkflowId: null,
|
||||
setFetchedSteps,
|
||||
} as unknown as DialogFormState;
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ open }) => useWorkflowStepsEffect(fs, open, null, "visible"),
|
||||
{ initialProps: { open: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(setFetchedSteps).toHaveBeenCalledWith(null));
|
||||
|
||||
rerender({ open: false });
|
||||
rerender({ open: true });
|
||||
|
||||
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledTimes(2));
|
||||
expect(workflowApiMocks.listWorkflowSteps).toHaveBeenNthCalledWith(2, "visible");
|
||||
await waitFor(() => expect(setFetchedSteps).toHaveBeenLastCalledWith([]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowAgentProfileEffect", () => {
|
||||
|
||||
@@ -30,19 +30,33 @@ export { decideAgentProfileAutopick } from "@/components/task-create-dialog-auto
|
||||
|
||||
const selectionDebug = createDebugLogger("task-create:selection");
|
||||
|
||||
export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) {
|
||||
const { selectedWorkflowId, setFetchedSteps } = fs;
|
||||
export function useWorkflowStepsEffect(
|
||||
fs: DialogFormState,
|
||||
open: boolean,
|
||||
workflowId: string | null,
|
||||
effectiveWorkflowId: string | null,
|
||||
) {
|
||||
const { setFetchedSteps } = fs;
|
||||
useEffect(() => {
|
||||
if (!selectedWorkflowId || selectedWorkflowId === workflowId) {
|
||||
void Promise.resolve().then(() => setFetchedSteps(null));
|
||||
void Promise.resolve().then(() => setFetchedSteps(null));
|
||||
if (!open || !effectiveWorkflowId || effectiveWorkflowId === workflowId) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
listWorkflowSteps(selectedWorkflowId)
|
||||
listWorkflowSteps(effectiveWorkflowId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const sorted = [...response.steps].sort((a, b) => a.position - b.position);
|
||||
setFetchedSteps(sorted.map((s) => ({ id: s.id, title: s.name, events: s.events })));
|
||||
setFetchedSteps(
|
||||
sorted.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
workflowId: effectiveWorkflowId,
|
||||
position: s.position,
|
||||
is_start_step: s.is_start_step,
|
||||
events: s.events,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetchedSteps(null);
|
||||
@@ -50,7 +64,7 @@ export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string |
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedWorkflowId, workflowId, setFetchedSteps]);
|
||||
}, [effectiveWorkflowId, open, workflowId, setFetchedSteps]);
|
||||
}
|
||||
|
||||
export function useDiscoverReposEffect(
|
||||
@@ -536,7 +550,8 @@ export function useGitHubUrlErrorEffect(fs: DialogFormState, open: boolean) {
|
||||
}
|
||||
|
||||
export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) {
|
||||
const { open, workspaceId, workflowId, repositories, repositoriesLoading } = args;
|
||||
const { open, workspaceId, workflowId, effectiveWorkflowId, repositories, repositoriesLoading } =
|
||||
args;
|
||||
const {
|
||||
agentProfiles,
|
||||
compatibleAgentProfiles,
|
||||
@@ -547,7 +562,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate
|
||||
workflows,
|
||||
isLocalExecutor,
|
||||
} = args;
|
||||
useWorkflowStepsEffect(fs, workflowId);
|
||||
useWorkflowStepsEffect(fs, open, workflowId, effectiveWorkflowId);
|
||||
useWorkflowAgentProfileEffect(fs, workflows, agentProfiles, compatibleAgentProfiles, {
|
||||
lastUsedAgentProfileId: args.lastUsedAgentProfileId,
|
||||
authLoaded,
|
||||
|
||||
@@ -72,6 +72,10 @@ export type TaskRemoteRepoRow = {
|
||||
export type StepType = {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Workflow that supplied these fallback-only steps. */
|
||||
workflowId?: string;
|
||||
position?: number;
|
||||
is_start_step?: boolean;
|
||||
events?: {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
@@ -176,6 +180,7 @@ export type TaskCreateEffectsArgs = {
|
||||
open: boolean;
|
||||
workspaceId: string | null;
|
||||
workflowId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
repositories: Repository[];
|
||||
repositoriesLoading: boolean;
|
||||
agentProfiles: AgentProfileOption[];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { resolveTaskCreateWorkflowContext } from "@/components/task-create-dialog-defaults";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
type WorkflowContextProps = {
|
||||
workflowId: string | null;
|
||||
defaultStepId: string | null;
|
||||
lockedFields?: { workflow?: boolean };
|
||||
};
|
||||
|
||||
export function useResolvedTaskCreateWorkflowContext<T extends WorkflowContextProps>(props: T): T {
|
||||
const workflows = useAppStore((state) => state.workflows?.items ?? []);
|
||||
const context = resolveTaskCreateWorkflowContext(
|
||||
props.workflowId,
|
||||
props.defaultStepId,
|
||||
workflows,
|
||||
props.lockedFields?.workflow === true,
|
||||
);
|
||||
return { ...props, ...context };
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { TaskCreateDialogPopoverContainerProvider } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import { shouldShowTaskTitleField } from "@/components/task-create-dialog-helpers";
|
||||
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
|
||||
import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-dialog-workflow-context";
|
||||
|
||||
const PROMPT_INSERTED_MESSAGE = "Enhanced prompt inserted.";
|
||||
|
||||
@@ -430,11 +431,11 @@ export function useTaskCreateDialogSetup(
|
||||
props: TaskCreateDialogProps,
|
||||
options: { preserveQueuedLastUsedOnClose?: () => void } = {},
|
||||
) {
|
||||
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props;
|
||||
const { editingTask, initialValues } = props;
|
||||
const resolvedProps = useResolvedTaskCreateWorkflowContext(props);
|
||||
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = resolvedProps;
|
||||
const { editingTask, initialValues } = resolvedProps;
|
||||
const isSessionMode = mode === "session";
|
||||
const isEditMode = mode === "edit";
|
||||
const isCreateMode = mode === "create";
|
||||
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
|
||||
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
|
||||
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
|
||||
@@ -457,6 +458,7 @@ export function useTaskCreateDialogSetup(
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
agentProfiles,
|
||||
@@ -481,7 +483,7 @@ export function useTaskCreateDialogSetup(
|
||||
upsertWorkspaceRepository,
|
||||
});
|
||||
const submitHandlers = useSubmitHandlersWiring({
|
||||
props,
|
||||
props: resolvedProps,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories: repositories,
|
||||
@@ -492,21 +494,18 @@ export function useTaskCreateDialogSetup(
|
||||
});
|
||||
const guardedHandleSubmit = useGuardedSubmit(
|
||||
submitHandlers.handleSubmit,
|
||||
props.submitBlockedReason,
|
||||
resolvedProps.submitBlockedReason,
|
||||
);
|
||||
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
|
||||
guardedHandleSubmit(event as unknown as FormEvent);
|
||||
});
|
||||
// Fresh-branch is single-row + local executor + not URL mode. The chip row
|
||||
// can hold any number of repos; we hide the toggle whenever the question
|
||||
// ("which repo do we discard local changes in?") becomes ambiguous.
|
||||
const freshBranchAvailable =
|
||||
!fs.useRemote && computed.isLocalExecutor && fs.repositories.length === 1;
|
||||
return {
|
||||
fs,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
isCreateMode,
|
||||
isCreateMode: mode === "create",
|
||||
isTaskStarted,
|
||||
sessionRepoName,
|
||||
workflows,
|
||||
@@ -523,7 +522,7 @@ export function useTaskCreateDialogSetup(
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
guardedHandleSubmit,
|
||||
enhance: useEnhanceForDialog(fs, props.taskId, props.open),
|
||||
enhance: useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open),
|
||||
handleJiraImport: useJiraImportHandler(fs),
|
||||
handleLinearImport: useLinearImportHandler(fs),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,45 @@ const START_ENABLED_TIMEOUT = 30_000;
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
test.describe("Task creation", () => {
|
||||
test("hidden workflow task detail does not leak into standard task creation", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const hidden = await apiClient.e2eCreateHiddenWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Hidden task-detail workflow",
|
||||
);
|
||||
const hiddenStart = await apiClient.createWorkflowStep(hidden.id, "Improve", 0, {
|
||||
is_start_step: true,
|
||||
});
|
||||
const sourceTask = await apiClient.createTask(seedData.workspaceId, "Hidden source task", {
|
||||
description: "Source task in the hidden workflow",
|
||||
workflow_id: hidden.id,
|
||||
workflow_step_id: hiddenStart.id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${sourceTask.id}`);
|
||||
await testPage.getByTestId("create-task-button").first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("task-title-input").fill("Visible workflow task");
|
||||
await dialog.getByTestId("task-description-input").fill("Created from hidden task detail");
|
||||
await expect(dialog.getByTestId(START_AGENT_TEST_ID)).toBeEnabled({
|
||||
timeout: START_ENABLED_TIMEOUT,
|
||||
});
|
||||
await dialog.getByTestId("submit-start-agent-chevron").click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
|
||||
await expect
|
||||
.poll(() => getTaskIdFromPage(testPage), { timeout: 15_000 })
|
||||
.not.toBe(sourceTask.id);
|
||||
const createdTaskId = await getTaskIdFromPage(testPage);
|
||||
const createdTask = await apiClient.getTask(createdTaskId);
|
||||
expect(createdTask.workflow_step_id).toBe(seedData.startStepId);
|
||||
});
|
||||
|
||||
test("selects the single visible workflow when hidden workflows are loaded", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Mobile task creation from hidden workflow context", () => {
|
||||
test("creates the new task in the visible workflow", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const hidden = await apiClient.e2eCreateHiddenWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Hidden mobile task-detail workflow",
|
||||
);
|
||||
const hiddenStart = await apiClient.createWorkflowStep(hidden.id, "Improve", 0, {
|
||||
is_start_step: true,
|
||||
});
|
||||
const sourceTask = await apiClient.createTask(seedData.workspaceId, "Hidden mobile source", {
|
||||
description: "Source task in the hidden workflow",
|
||||
workflow_id: hidden.id,
|
||||
workflow_step_id: hiddenStart.id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${sourceTask.id}`);
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
await expect(taskDrawer).toBeVisible();
|
||||
await taskDrawer.getByRole("button", { name: "New", exact: true }).tap();
|
||||
|
||||
const createDialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(createDialog).toBeVisible();
|
||||
await createDialog.getByTestId("task-title-input").fill("Visible mobile workflow task");
|
||||
await createDialog
|
||||
.getByTestId("task-description-input")
|
||||
.fill("Created from hidden mobile task detail");
|
||||
const startAgent = createDialog.getByTestId("submit-start-agent");
|
||||
await expect(startAgent).toBeEnabled({ timeout: 30_000 });
|
||||
const createOnly = createDialog.getByRole("button", { name: "Create only", exact: true });
|
||||
await expect(createOnly).toBeEnabled();
|
||||
await createOnly.tap();
|
||||
|
||||
await expect
|
||||
.poll(() => taskIdFromURL(testPage.url()), { timeout: 15_000 })
|
||||
.not.toBe(sourceTask.id);
|
||||
const createdTaskId = taskIdFromURL(testPage.url());
|
||||
const createdTask = await apiClient.getTask(createdTaskId);
|
||||
expect(createdTask.workflow_step_id).toBe(seedData.startStepId);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function taskIdFromURL(url: string): string {
|
||||
const match = url.match(/\/t\/([^/?]+)/);
|
||||
if (!match) throw new Error(`Cannot extract task ID from URL: ${url}`);
|
||||
return match[1];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
spec: docs/specs/improve-kandev/spec.md
|
||||
created: 2026-07-29
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Standard Task Creation From Hidden Workflow Context
|
||||
|
||||
## Overview
|
||||
|
||||
The task-detail boot payload correctly exposes the current task's hidden
|
||||
workflow so the task can render, but standard New Task entry points pass that
|
||||
context straight into the shared task-create dialog. The dialog treats it as an
|
||||
explicit selection, so a task created while viewing an Improve Kandev task can
|
||||
silently inherit the hidden Improve workflow.
|
||||
|
||||
Normalize the workflow context at the shared task-create boundary: ordinary
|
||||
task creation must discard a hidden contextual workflow and fall back to the
|
||||
workspace's visible workflows, while a feature wrapper with a locked workflow
|
||||
continues to preserve its explicit hidden workflow. Fetch the visible
|
||||
workflow's steps when the normalized fallback differs from the caller-provided
|
||||
context, then prove the behavior through unit and desktop/mobile E2E coverage.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared workflow-context normalization
|
||||
|
||||
- Add a pure workflow-context resolver in
|
||||
`apps/web/components/task-create-dialog-defaults.ts`.
|
||||
- When the caller-provided workflow is hidden and
|
||||
`lockedFields.workflow !== true`, clear the provided workflow and default
|
||||
step before initializing, resetting, or submitting the dialog. This lets the
|
||||
existing visible-only single-workflow fallback choose the workspace's Kanban
|
||||
workflow.
|
||||
- Preserve visible workflows and preserve explicitly locked hidden workflows,
|
||||
including the Improve Kandev wrapper.
|
||||
- Apply the normalized workflow/default-step context consistently in
|
||||
`apps/web/components/task-create-dialog.tsx`; do not change the task-detail
|
||||
boot state because it still needs the hidden workflow to render the task.
|
||||
|
||||
### Fallback step loading
|
||||
|
||||
- Extend the workflow-step effect in
|
||||
`apps/web/components/task-create-dialog-effects.ts` to load steps for the
|
||||
computed effective workflow when it differs from the normalized
|
||||
caller-provided workflow.
|
||||
- Update the default-step resolver in
|
||||
`apps/web/components/task-create-dialog-defaults.ts` so fetched steps for the
|
||||
effective fallback supply its start/first step. Do not reuse the hidden
|
||||
workflow's step ID.
|
||||
- Keep the existing explicit workflow-switch behavior unchanged.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- Desktop entry point remains the app-sidebar **New Task** action.
|
||||
- Mobile entry point remains the **New** action in
|
||||
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx`, the nearest
|
||||
shipped mobile exemplar.
|
||||
- The existing task drawer, task-create dialog, hierarchy, scroll ownership,
|
||||
safe-area behavior, and touch targets do not change. Desktop and mobile share
|
||||
the same workflow normalization and step-loading logic.
|
||||
- Mobile Playwright coverage creates a task from the existing task drawer while
|
||||
viewing a hidden-workflow task and verifies the visible workflow receives it.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** an unlocked hidden contextual workflow is removed, while a locked
|
||||
hidden workflow and an ordinary visible workflow are preserved.
|
||||
**File:** `apps/web/components/task-create-dialog-defaults.test.ts`.
|
||||
**How:** table-driven pure-function tests.
|
||||
- **What:** a visible fallback workflow that differs from the provided context
|
||||
fetches its steps and resolves the visible start step.
|
||||
**Files:** `apps/web/components/task-create-dialog-effects.test.ts` and
|
||||
`apps/web/components/task-create-dialog-defaults.test.ts`.
|
||||
**How:** focused hook and pure-function tests that fail against the current
|
||||
selected-workflow-only loading rule.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN a task in a hidden workflow, WHEN the user opens the
|
||||
desktop sidebar New Task dialog and creates a task, THEN the created task's
|
||||
workflow step belongs to the visible Kanban workflow.
|
||||
**File:** `apps/web/e2e/tests/task/create-task.spec.ts`.
|
||||
**What to verify:** seed a hidden workflow and task, navigate directly to the
|
||||
task, create without an agent through the sidebar, and query the created task
|
||||
to compare `workflow_step_id` with the visible workflow's start step.
|
||||
- **Scenario:** GIVEN the same hidden task on a phone viewport, WHEN the user
|
||||
opens the mobile task drawer, taps **New**, and creates a task, THEN the task
|
||||
uses the visible workflow.
|
||||
**File:** `apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts`.
|
||||
**What to verify:** drive the existing drawer and dialog with touch-oriented
|
||||
interactions, then assert the created task's workflow step and absence of
|
||||
document horizontal overflow.
|
||||
|
||||
## Implementation Task
|
||||
|
||||
- [x] [task-01-normalize-task-create-workflow](task-01-normalize-task-create-workflow.md)
|
||||
— done
|
||||
|
||||
The task performs unit and desktop/mobile E2E RED runs before changing
|
||||
production code, then implements and reruns the same focused checks GREEN.
|
||||
After the targeted checks pass, run the workflow-requested repository gates in
|
||||
order (`make fmt`, then `make typecheck test lint`) and commit with a
|
||||
Conventional Commit message.
|
||||
|
||||
## Risks And Out Of Scope
|
||||
|
||||
- Hidden workflows remain stored in SQLite and present in task-detail boot
|
||||
state; they are not database corruption.
|
||||
- This fix does not change workflow visibility APIs, migrations, workflow
|
||||
persistence, or the Improve Kandev bootstrap contract.
|
||||
- The linked task ID is absent from the current database, so its original
|
||||
request cannot be reconstructed from persisted rows; the current task and
|
||||
task-detail boot payload reproduce the same failure path.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "01-normalize-task-create-workflow"
|
||||
title: "Normalize standard task-create workflow context"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/improve-kandev/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Normalize standard task-create workflow context
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Standard task creation ignores a hidden workflow inherited from task-detail
|
||||
state and resolves the workspace's sole visible workflow.
|
||||
- Feature wrappers with `lockedFields.workflow` continue to create tasks in
|
||||
their explicitly supplied hidden workflow.
|
||||
- When normalization selects a different workflow, its fetched start/first step
|
||||
becomes the submitted default step; no step from the hidden workflow leaks.
|
||||
- Desktop sidebar and mobile task-drawer E2E flows both prove that a task
|
||||
created from a hidden-workflow task lands in the visible workflow.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm install --frozen-lockfile` (required once in a fresh worktree)
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-defaults.test.ts components/task-create-dialog-effects.test.ts`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
- `cd apps/web && pnpm e2e:run tests/task/create-task.spec.ts -- --grep "hidden workflow task detail"`
|
||||
- `cd apps/web && pnpm e2e:run tests/task/mobile-hidden-workflow-task-create.spec.ts -- --project=mobile-chrome`
|
||||
- `make fmt`
|
||||
- `make typecheck test lint`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task-create-dialog-defaults.ts`
|
||||
- `apps/web/components/task-create-dialog-defaults.test.ts`
|
||||
- `apps/web/components/task-create-dialog-effects.ts`
|
||||
- `apps/web/components/task-create-dialog-effects.test.ts`
|
||||
- `apps/web/components/task-create-dialog-types.ts`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/e2e/tests/task/create-task.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts` only if the existing task response
|
||||
helper needs to expose `workflow_id` for a behavioral assertion
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Hidden-workflow requirements and task-detail regression scenario in
|
||||
`docs/specs/improve-kandev/spec.md`.
|
||||
- Frontend approach in `plan.md`.
|
||||
- Existing visible fallback in
|
||||
`apps/web/components/task-create-dialog-defaults.ts`.
|
||||
- Explicit hidden workflow wrapper in
|
||||
`apps/web/components/improve-kandev-dialog-create.tsx`.
|
||||
- Existing hidden workflow E2E seeding in
|
||||
`apps/web/e2e/helpers/api-client.ts`.
|
||||
- Existing mobile task drawer in
|
||||
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the unit and E2E RED/GREEN command results, changed files, failure
|
||||
artifacts, remaining risks, and update this task to `done` plus the
|
||||
corresponding plan checkbox in the primary conversation.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- RED: focused dialog tests failed in five expected assertions before
|
||||
implementation (missing context resolver, missing visible-step fetch, and
|
||||
missing visible start-step selection).
|
||||
- RED: desktop Chromium created the new task with the hidden workflow step
|
||||
instead of the visible Kanban start step.
|
||||
- GREEN: 48 focused task-create dialog tests passed.
|
||||
- GREEN: desktop Chromium and mobile Chrome hidden-workflow creation scenarios
|
||||
passed, including the persisted step assertion and mobile overflow check.
|
||||
- GREEN: `make fmt`.
|
||||
- GREEN: `make typecheck test lint`.
|
||||
@@ -53,7 +53,11 @@ the user's own agent picks up immediately — turning every report into a contri
|
||||
dialog. It is reachable only through the Improve Kandev entry point.
|
||||
- Hidden workflows do not count as choices in the standard task-create dialog.
|
||||
When the active workspace has exactly one visible workflow, the dialog uses
|
||||
that workflow implicitly and omits the redundant workflow selector.
|
||||
that workflow implicitly and omits the redundant workflow selector. This
|
||||
remains true when the standard dialog is opened from a task-detail route
|
||||
whose task belongs to a hidden workflow; only a feature wrapper that
|
||||
explicitly locks the workflow may create another task in that hidden
|
||||
workflow.
|
||||
- The `report-kandev-issue` workflow is also hidden and reachable only through
|
||||
the **Open issue** option. Its agent reads the repository's current bug-report
|
||||
or feature-request issue form, gathers every required field from the user,
|
||||
@@ -142,6 +146,12 @@ the user's own agent picks up immediately — turning every report into a contri
|
||||
explicit workflow, **THEN** the visible workflow is selected implicitly and
|
||||
the workflow selector does not appear.
|
||||
|
||||
- **GIVEN** the user is viewing a task that belongs to a hidden Improve Kandev
|
||||
workflow, **WHEN** they open the standard New Task dialog from either the
|
||||
desktop sidebar or the mobile task drawer and create a task, **THEN** the new
|
||||
task uses the workspace's visible workflow rather than inheriting the hidden
|
||||
task-detail workflow.
|
||||
|
||||
- **GIVEN** the user has not configured `gh auth`, **WHEN** they open the
|
||||
Improve Kandev dialog, **THEN** the dialog shows a blocking error referencing
|
||||
the health-check result and disables the submit button.
|
||||
|
||||
Reference in New Issue
Block a user