diff --git a/apps/web/components/task-create-dialog-defaults.test.ts b/apps/web/components/task-create-dialog-defaults.test.ts index 39d53e1f90..4d569ae842 100644 --- a/apps/web/components/task-create-dialog-defaults.test.ts +++ b/apps/web/components/task-create-dialog-defaults.test.ts @@ -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", + }); + }); +}); diff --git a/apps/web/components/task-create-dialog-defaults.ts b/apps/web/components/task-create-dialog-defaults.ts index 157e0f2959..57eba502d2 100644 --- a/apps/web/components/task-create-dialog-defaults.ts +++ b/apps/web/components/task-create-dialog-defaults.ts @@ -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); } diff --git a/apps/web/components/task-create-dialog-effects.test.ts b/apps/web/components/task-create-dialog-effects.test.ts index 1c377e3e2c..a9f27f0620 100644 --- a/apps/web/components/task-create-dialog-effects.test.ts +++ b/apps/web/components/task-create-dialog-effects.test.ts @@ -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", () => { diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 9f0de81e03..62d2fa54db 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -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, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index faea9f2b56..2bb93641bd 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -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 }>; on_turn_complete?: Array<{ type: string; config?: Record }>; @@ -176,6 +180,7 @@ export type TaskCreateEffectsArgs = { open: boolean; workspaceId: string | null; workflowId: string | null; + effectiveWorkflowId: string | null; repositories: Repository[]; repositoriesLoading: boolean; agentProfiles: AgentProfileOption[]; diff --git a/apps/web/components/task-create-dialog-workflow-context.ts b/apps/web/components/task-create-dialog-workflow-context.ts new file mode 100644 index 0000000000..172b19495b --- /dev/null +++ b/apps/web/components/task-create-dialog-workflow-context.ts @@ -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(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 }; +} diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 3d79f7a2b1..68a6eec71a 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -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), }; diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts index b300528beb..e7bebd1857 100644 --- a/apps/web/e2e/tests/task/create-task.spec.ts +++ b/apps/web/e2e/tests/task/create-task.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts b/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts new file mode 100644 index 0000000000..bbad04d46e --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts @@ -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]; +} diff --git a/docs/plans/standard-task-create-hidden-workflow-context/plan.md b/docs/plans/standard-task-create-hidden-workflow-context/plan.md new file mode 100644 index 0000000000..d0fcfea47d --- /dev/null +++ b/docs/plans/standard-task-create-hidden-workflow-context/plan.md @@ -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. diff --git a/docs/plans/standard-task-create-hidden-workflow-context/task-01-normalize-task-create-workflow.md b/docs/plans/standard-task-create-hidden-workflow-context/task-01-normalize-task-create-workflow.md new file mode 100644 index 0000000000..0caa561d6d --- /dev/null +++ b/docs/plans/standard-task-create-hidden-workflow-context/task-01-normalize-task-create-workflow.md @@ -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`. diff --git a/docs/specs/improve-kandev/spec.md b/docs/specs/improve-kandev/spec.md index 71bbb5ef50..a16318c3f0 100644 --- a/docs/specs/improve-kandev/spec.md +++ b/docs/specs/improve-kandev/spec.md @@ -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.