fix(web): prevent hidden workflow task inheritance (#2031)

This commit is contained in:
Carlos Florêncio
2026-07-29 12:13:14 +01:00
committed by GitHub
parent 7ec82dcaec
commit d162b11051
12 changed files with 639 additions and 24 deletions
@@ -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 };
}
+9 -10
View File
@@ -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.
@@ -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`.
+11 -1
View File
@@ -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.