From 009a88278cf0cee9146cfe3238eb63352d2b2179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 13 Aug 2026 23:16:37 +0100 Subject: [PATCH] feat: improve task dependency selector (#2627) --- ...k-create-dialog-advanced-settings.test.tsx | 144 +++++++++ .../task-create-dialog-advanced-settings.tsx | 100 ++++++ .../task-create-dialog-dependencies.test.tsx | 166 ++++++++++ .../task-create-dialog-dependencies.tsx | 304 +++++++++++------- .../task-create-dialog-form-body.test.tsx | 63 +++- .../task-create-dialog-form-body.tsx | 7 +- .../components/task-create-dialog.test.tsx | 1 + apps/web/components/task-create-dialog.tsx | 16 +- .../create-task-dependency-selector.spec.ts | 163 ++++++++++ ...le-create-task-dependency-selector.spec.ts | 95 ++++++ apps/web/src/locales/en/task.json | 6 + apps/web/src/locales/pseudo/task.json | 6 + .../task-create-advanced-settings/plan.md | 133 ++++++++ .../task-01-advanced-settings-ui.md | 113 +++++++ .../task-02-advanced-settings-e2e.md | 85 +++++ .../task-create-dependency-selector/plan.md | 151 +++++++++ .../task-01-selector-ui.md | 103 ++++++ .../task-02-selector-e2e.md | 82 +++++ docs/specs/INDEX.md | 2 + .../create-dialog-advanced-settings.md | 135 ++++++++ .../create-dialog-dependency-selector.md | 105 ++++++ 21 files changed, 1858 insertions(+), 122 deletions(-) create mode 100644 apps/web/components/task-create-dialog-advanced-settings.test.tsx create mode 100644 apps/web/components/task-create-dialog-advanced-settings.tsx create mode 100644 apps/web/components/task-create-dialog-dependencies.test.tsx create mode 100644 apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts create mode 100644 docs/plans/task-create-advanced-settings/plan.md create mode 100644 docs/plans/task-create-advanced-settings/task-01-advanced-settings-ui.md create mode 100644 docs/plans/task-create-advanced-settings/task-02-advanced-settings-e2e.md create mode 100644 docs/plans/task-create-dependency-selector/plan.md create mode 100644 docs/plans/task-create-dependency-selector/task-01-selector-ui.md create mode 100644 docs/plans/task-create-dependency-selector/task-02-selector-e2e.md create mode 100644 docs/specs/task-dependencies/create-dialog-advanced-settings.md create mode 100644 docs/specs/task-dependencies/create-dialog-dependency-selector.md diff --git a/apps/web/components/task-create-dialog-advanced-settings.test.tsx b/apps/web/components/task-create-dialog-advanced-settings.test.tsx new file mode 100644 index 0000000000..e2e4dbc3f4 --- /dev/null +++ b/apps/web/components/task-create-dialog-advanced-settings.test.tsx @@ -0,0 +1,144 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { TaskCreateAdvancedSettings } from "./task-create-dialog-advanced-settings"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const ADVANCED_SETTINGS_TRIGGER_TEST_ID = "task-create-advanced-settings-trigger"; +const DEPENDENCY_TRIGGER_TEST_ID = "task-create-dependencies-trigger"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + "task:advancedSettings": "Advanced settings", + "task:dependsOn": "Depends on", + "task:dependencyInfoLabel": "About task dependencies", + "task:dependencyInfo": "This task waits until every selected task completes successfully.", + })[key] ?? key, + }), +})); + +vi.mock("@/components/task-create-dialog-dependencies", () => ({ + TaskCreateDependencies: ({ + value, + onChange, + }: { + value: string[]; + onChange: (next: string[]) => void; + }) => ( + + ), +})); + +function renderAdvancedSettings( + overrides: Partial> = {}, +) { + return render( + + {}} + {...overrides} + /> + , + ); +} + +describe("TaskCreateAdvancedSettings", () => { + it("starts collapsed and keeps the dependency selector hidden", () => { + renderAdvancedSettings(); + + const trigger = screen.getByTestId(ADVANCED_SETTINGS_TRIGGER_TEST_ID); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.className).toContain("min-h-12"); + expect(screen.queryByTestId(DEPENDENCY_TRIGGER_TEST_ID)).toBeNull(); + }); + + it("reveals the dependency selector when expanded", () => { + renderAdvancedSettings(); + + const trigger = screen.getByTestId(ADVANCED_SETTINGS_TRIGGER_TEST_ID); + fireEvent.click(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTestId(DEPENDENCY_TRIGGER_TEST_ID).getAttribute("hidden")).toBeNull(); + }); + + it("labels the dependency setting and provides contextual help", async () => { + renderAdvancedSettings(); + + fireEvent.click(screen.getByTestId(ADVANCED_SETTINGS_TRIGGER_TEST_ID)); + + expect(screen.getByTestId("task-create-dependency-setting-label").textContent).toContain( + "Depends on", + ); + const grid = screen.getByTestId("task-create-advanced-settings-grid"); + const row = screen.getByTestId("task-create-dependency-setting-row"); + const selectorContainer = screen.getByTestId("task-create-dependency-selector-container"); + expect(grid.className).toContain("md:grid-cols-2"); + expect(row.className).toContain("items-center"); + expect(row.className).toContain("gap-3"); + expect(row.className).not.toContain("flex-col"); + expect(row.parentElement).toBe(grid); + expect(selectorContainer.parentElement).toBe(row); + const info = screen.getByTestId("task-create-dependency-setting-info"); + expect(info.getAttribute("aria-label")).toBe("About task dependencies"); + + fireEvent.focus(info); + await waitFor(() => { + expect(screen.getByRole("tooltip").textContent).toContain( + "This task waits until every selected task completes successfully.", + ); + }); + }); + + it("preserves selected dependencies across collapse and reopen", () => { + function Harness() { + const [blockedBy, setBlockedBy] = useState(["task-1"]); + return ( + + + + ); + } + + render(); + const trigger = screen.getByTestId(ADVANCED_SETTINGS_TRIGGER_TEST_ID); + fireEvent.click(trigger); + fireEvent.click(screen.getByTestId(DEPENDENCY_TRIGGER_TEST_ID)); + expect(screen.getByTestId(DEPENDENCY_TRIGGER_TEST_ID).textContent).toContain("2 dependencies"); + + fireEvent.click(trigger); + expect(screen.queryByTestId(DEPENDENCY_TRIGGER_TEST_ID)).toBeNull(); + fireEvent.click(trigger); + expect(screen.getByTestId(DEPENDENCY_TRIGGER_TEST_ID).textContent).toContain("2 dependencies"); + }); + + it.each([ + ["edit mode", { isCreateMode: false, isTaskStarted: false }], + ["started task", { isCreateMode: true, isTaskStarted: true }], + ])("does not render for a %s", (_name, props) => { + renderAdvancedSettings(props); + + expect(screen.queryByTestId(ADVANCED_SETTINGS_TRIGGER_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task-create-dialog-advanced-settings.tsx b/apps/web/components/task-create-dialog-advanced-settings.tsx new file mode 100644 index 0000000000..4acc3f0594 --- /dev/null +++ b/apps/web/components/task-create-dialog-advanced-settings.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { IconChevronDown, IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; +import { TaskCreateDependencies } from "@/components/task-create-dialog-dependencies"; +import { cn } from "@/lib/utils"; + +type TaskCreateAdvancedSettingsProps = { + isCreateMode: boolean; + isTaskStarted: boolean; + blockedBy: string[]; + onBlockedByChange: (next: string[]) => void; + dependenciesDisabled?: boolean; +}; + +export function TaskCreateAdvancedSettings({ + isCreateMode, + isTaskStarted, + blockedBy, + onBlockedByChange, + dependenciesDisabled, +}: TaskCreateAdvancedSettingsProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + if (!isCreateMode || isTaskStarted) return null; + + return ( + + + + + +
+
+
+ {t("task:dependsOn")} + + + + + + {t("task:dependencyInfo")} + + +
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/components/task-create-dialog-dependencies.test.tsx b/apps/web/components/task-create-dialog-dependencies.test.tsx new file mode 100644 index 0000000000..99954ae2ec --- /dev/null +++ b/apps/web/components/task-create-dialog-dependencies.test.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import { TaskCreateDependencies } from "./task-create-dialog-dependencies"; + +type MockStore = { + kanban: { tasks: KanbanState["tasks"] }; + kanbanMulti: { snapshots: Record }; +}; + +const ALPHA_ID = "task-alpha"; +const BETA_ID = "task-beta"; +const ALPHA_TITLE = "Alpha task"; +const BETA_TITLE = "Beta task"; +const NO_DEPENDENCY_LABEL = "No dependency"; +const SEARCH_TASKS_LABEL = "Search tasks..."; +const INFO_LABEL = "About task dependencies"; +const TWO_DEPENDENCIES_LABEL = "2 dependencies"; +const TRIGGER_TEST_ID = "task-create-dependencies-trigger"; + +let mockStore: MockStore = { + kanban: { tasks: [] }, + kanbanMulti: { snapshots: {} }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockStore) => unknown) => selector(mockStore), +})); + +function task( + id: string, + title: string, + overrides: Partial = {}, +): KanbanState["tasks"][number] { + return { + id, + workflowId: "workflow-1", + workflowStepId: "step-1", + title, + position: 0, + isArchived: false, + ...overrides, + }; +} + +function renderDependencies(value: string[] = [], onChange = vi.fn()) { + return render( + + + , + ); +} + +function ControlledDependencies({ initialValue = [] }: { initialValue?: string[] }) { + const [value, setValue] = useState(initialValue); + return ; +} + +afterEach(() => { + cleanup(); + mockStore = { + kanban: { tasks: [] }, + kanbanMulti: { snapshots: {} }, + }; +}); + +describe("TaskCreateDependencies", () => { + it("starts with a no-dependency selector and its dependency icon", () => { + renderDependencies(); + + const trigger = screen.getByTestId(TRIGGER_TEST_ID); + expect(trigger.textContent).toContain(NO_DEPENDENCY_LABEL); + expect(trigger.querySelector("svg")).not.toBeNull(); + }); + + it("shows searchable non-archived tasks with task icons and teaching help", async () => { + mockStore = { + kanban: { + tasks: [ + task(ALPHA_ID, ALPHA_TITLE), + task("task-archived", "Archived task", { isArchived: true }), + ], + }, + kanbanMulti: { + snapshots: { + other: { tasks: [task(BETA_ID, BETA_TITLE)] }, + }, + }, + }; + + renderDependencies(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + const picker = screen.getByTestId("task-create-dependencies-popover"); + expect(within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL)).toBeTruthy(); + expect(within(picker).getByTestId("task-create-dependency-info")).toBeTruthy(); + expect(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).toBeTruthy(); + expect(within(picker).getByTestId(`task-create-dependency-option-${BETA_ID}`)).toBeTruthy(); + expect(within(picker).queryByText("Archived task")).toBeNull(); + expect( + within(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).getByTestId( + "task-create-dependency-task-icon", + ), + ).toBeTruthy(); + + const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL); + fireEvent.change(search, { target: { value: "Beta" } }); + expect(within(picker).getByText(BETA_TITLE)).toBeTruthy(); + expect(within(picker).queryByText(ALPHA_TITLE)).toBeNull(); + expect(within(picker).getByTestId("task-create-no-dependency")).toBeTruthy(); + + const info = within(picker).getByTestId("task-create-dependency-info"); + expect(info.getAttribute("aria-label")).toBe(INFO_LABEL); + fireEvent.focus(info); + await waitFor(() => { + expect(screen.getByRole("tooltip").textContent).toMatch(/waits until every selected task/i); + }); + }); + + it("toggles multiple predecessors and exposes a localized count", () => { + mockStore = { + kanban: { + tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)], + }, + kanbanMulti: { snapshots: {} }, + }; + + render( + + + , + ); + fireEvent.click(screen.getByTestId("task-create-dependencies-trigger")); + + fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`)); + expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(ALPHA_TITLE); + + fireEvent.click(screen.getByTestId(`task-create-dependency-option-${BETA_ID}`)); + expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL); + + fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`)); + expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(BETA_TITLE); + }); + + it("clears all selected predecessors from the no-dependency entry", () => { + mockStore = { + kanban: { + tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)], + }, + kanbanMulti: { snapshots: {} }, + }; + + render( + + + , + ); + expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + fireEvent.click(screen.getByTestId("task-create-no-dependency")); + + expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(NO_DEPENDENCY_LABEL); + }); +}); diff --git a/apps/web/components/task-create-dialog-dependencies.tsx b/apps/web/components/task-create-dialog-dependencies.tsx index abab7b167e..ec79a18ca1 100644 --- a/apps/web/components/task-create-dialog-dependencies.tsx +++ b/apps/web/components/task-create-dialog-dependencies.tsx @@ -1,19 +1,14 @@ "use client"; -/** - * "Depends on" selector for the task-create dialog. - * - * Dependencies are declared when the task is created (or later through MCP), so - * this is the only place the UI writes an edge. Selecting one or more - * predecessors also changes what "Start agent" means: the backend records the - * start as a start-when-unblocked intent, and the task launches on its own once - * every predecessor completes successfully. - */ - import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { IconLock, IconX } from "@tabler/icons-react"; -import { Badge } from "@kandev/ui/badge"; +import { + IconCheck, + IconChevronDown, + IconInfoCircle, + IconListCheck, + IconLock, +} from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Command, @@ -24,14 +19,15 @@ import { CommandList, } from "@kandev/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import { cn } from "@/lib/utils"; -/** - * Stable empty fallbacks. A selector must not build a fresh array/object: under - * Object.is that reads as a change on every store read and re-renders until React - * bails out (which is what silently blanked the dependency chip once already). - */ +// Keep stable empty fallbacks here. Creating a fresh array/object in a store +// selector makes Object.is report a change on every read and can cause a +// render loop, which previously blanked the dependency chip. const NO_TASKS: KanbanState["tasks"] = []; const NO_SNAPSHOTS: Record = {}; @@ -42,17 +38,12 @@ export type TaskCreateDependenciesProps = { disabled?: boolean; }; -/** - * Every task the user could depend on, from BOTH board slices. - * - * The home route hydrates its swimlanes into kanbanMulti.snapshots and can leave - * kanban.tasks empty, so reading only the latter left this picker with nothing to - * offer on the very route where tasks are created. Both selections are stable - * references; the dedupe happens in the memo. - */ function useBoardTasks(): KanbanState["tasks"] { const boardTasks = useAppStore((state) => state.kanban?.tasks ?? NO_TASKS); const snapshots = useAppStore((state) => state.kanbanMulti?.snapshots ?? NO_SNAPSHOTS); + + // The home route hydrates its swimlanes into kanbanMulti.snapshots and can + // leave kanban.tasks empty, so both slices must supply picker candidates. return useMemo(() => { const byId = new Map(); for (const task of boardTasks) byId.set(task.id, task); @@ -65,98 +56,195 @@ function useBoardTasks(): KanbanState["tasks"] { }, [boardTasks, snapshots]); } +function DependencyTriggerLabel({ + value, + selectedTitle, + t, +}: { + value: string[]; + selectedTitle?: string; + t: (key: string, options?: { count?: number }) => string; +}) { + if (value.length === 0) return t("task:noDependency"); + if (value.length === 1 && selectedTitle) return selectedTitle; + return t("task:dependencyCount", { count: value.length }); +} + +function DependencyOption({ + task, + selected, + onSelect, +}: { + task: KanbanState["tasks"][number]; + selected: boolean; + onSelect: () => void; +}) { + return ( + + + ); +} + +type DependencyPickerContentProps = { + candidates: KanbanState["tasks"]; + value: string[]; + onChange: (next: string[]) => void; + setOpen: (open: boolean) => void; + portalContainer: HTMLElement | null; +}; + +function DependencyPickerContent({ + candidates, + value, + onChange, + setOpen, + portalContainer, +}: DependencyPickerContentProps) { + const { t } = useTranslation(); + return ( + event.stopPropagation()} + > + +
+
+ +
+ + + + + + {t("task:dependencyInfo")} + + +
+ + {t("task:noTasksFound")} + + { + onChange([]); + setOpen(false); + }} + className="min-h-12 cursor-pointer gap-2 md:min-h-11" + data-testid="task-create-no-dependency" + aria-selected={value.length === 0} + > + + {candidates.map((task) => ( + { + onChange( + value.includes(task.id) + ? value.filter((id) => id !== task.id) + : [...value, task.id], + ); + }} + /> + ))} + + +
+
+ ); +} + export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreateDependenciesProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - // Select the stable slice reference and derive below: a selector that builds a - // new array compares unequal on every store read and re-renders until React - // gives up. - // Candidates come from BOTH board slices. The home route hydrates its - // swimlanes into kanbanMulti.snapshots and can leave kanban.tasks empty, so - // reading only the latter left this picker with nothing to offer on the very - // route where tasks are created. Both selections are stable references; the - // dedupe happens in the memo below. + const portalContainer = useTaskCreateDialogPopoverContainer(); const tasks = useBoardTasks(); - const selected = useMemo( - () => value.map((id) => tasks.find((task) => task.id === id)).filter(Boolean), - [value, tasks], - ); + const selectedTitle = useMemo(() => { + if (value.length !== 1) return undefined; + return tasks.find((task) => task.id === value[0])?.title; + }, [tasks, value]); const candidates = useMemo( - () => - tasks - .filter((task) => !value.includes(task.id) && !task.isArchived) - .sort((a, b) => a.title.localeCompare(b.title)), - [tasks, value], + () => tasks.filter((task) => !task.isArchived).sort((a, b) => a.title.localeCompare(b.title)), + [tasks], ); + const triggerLabel = ; return ( -
-
- {t("task:dependsOn")} - - - - - - - - - {t("task:noDependencies")} - - {candidates.map((task) => ( - { - setOpen(false); - onChange([...value, task.id]); - }} - > - {task.title} - - ))} - - - - - -
- {selected.length > 0 && ( -
- {selected.map((task) => ( - - {task!.title} - - - ))} -
- )} - {selected.length > 0 && ( -

{t("task:dependsOnWillAutoStart")}

- )} +
+ { + if (!disabled) setOpen(next); + }} + > + + + + +
); } diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 2d18ec0012..3cd07c5859 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -1,6 +1,6 @@ import { createRef } from "react"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CreateEditSelectors, DialogPromptSection, @@ -8,12 +8,22 @@ import { } from "./task-create-dialog-form-body"; import type { DialogFormState, TaskFormInputsHandle } from "./task-create-dialog-types"; +afterEach(cleanup); + vi.mock("@/components/workflow-selector-row", () => ({ WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => ( ), })); +vi.mock("@/components/task-create-dialog-dependencies", () => ({ + TaskCreateDependencies: ({ value }: { value: string[] }) => ( + + ), +})); + // Capture every render of TaskFormInputs so passthrough-related assertions can // inspect the props that DialogPromptSection forwards to the textarea. const taskFormInputsCalls: Array> = []; @@ -46,7 +56,7 @@ function renderWorkflowSection(effectiveWorkflowId: string | null) { ); } -describe("WorkflowSection", () => { +describe("WorkflowSection workflow rendering", () => { it("keeps the selector reachable when no effective workflow is selected", () => { renderWorkflowSection(null); @@ -54,9 +64,52 @@ describe("WorkflowSection", () => { }); it("does not show redundant selector for a selected single workflow without overrides", () => { - const { container } = renderWorkflowSection("wf-1"); + renderWorkflowSection("wf-1"); - expect(container.textContent).toBe(""); + expect(screen.queryByRole("button", { name: /workflow selector wf-1/i })).toBeNull(); + expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull(); + }); +}); + +describe("WorkflowSection", () => { + const secondWorkflow = { id: "wf-2", name: "Support" }; + + function renderWorkflowSection( + effectiveWorkflowId: string | null, + workflows = [workflow, secondWorkflow], + ) { + return render( + {}} + agentProfiles={[]} + />, + ); + } + + it("renders the workflow selector without an inline dependency slot", () => { + renderWorkflowSection("wf-1"); + + expect(screen.getByRole("button", { name: /workflow selector wf-1/i })).toBeTruthy(); + expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull(); + }); + + it("does not render a workflow row for a single workflow without overrides", () => { + renderWorkflowSection("wf-1", [workflow]); + + expect(screen.queryByRole("button", { name: /workflow selector wf-1/i })).toBeNull(); + expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull(); + }); + + it("keeps the workflow section independent from advanced dependencies", () => { + renderWorkflowSection("wf-1"); + + expect(screen.getByRole("button", { name: /workflow selector wf-1/i })).toBeTruthy(); + expect(screen.queryByTestId("task-create-dependency-slot")).toBeNull(); }); }); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 00f58df0de..57ae72c77d 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -283,7 +283,7 @@ type WorkflowSectionProps = { workflowLocked?: boolean; }; -export const WorkflowSection = memo(function WorkflowSection({ +function renderWorkflowSection({ isCreateMode, isTaskStarted, workflows: allWorkflows, @@ -351,6 +351,11 @@ export const WorkflowSection = memo(function WorkflowSection({ } return null; +} + +export const WorkflowSection = memo(function WorkflowSection(workflowProps: WorkflowSectionProps) { + if (!workflowProps.isCreateMode || workflowProps.isTaskStarted) return null; + return renderWorkflowSection(workflowProps); }); export type DialogPromptSectionProps = { diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index db4bc56f0e..ecf34229bf 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -85,6 +85,7 @@ vi.mock("@/components/task-create-dialog-repo-chips", () => ({ })); vi.mock("@/hooks/use-task-create-dialog-popover-container", () => ({ + useTaskCreateDialogPopoverContainer: () => null, TaskCreateDialogPopoverContainerProvider: ({ children }: { children: ReactNode }) => ( <>{children} ), diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 824c427411..5e0126fd9b 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -16,13 +16,13 @@ import { DialogPromptSection, } from "@/components/task-create-dialog-form-body"; import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors"; -import { TaskCreateDependencies } from "@/components/task-create-dialog-dependencies"; import { AgentSelector, ExecutorProfileSelector, InlineTaskName, } from "@/components/task-create-dialog-selectors"; import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips"; +import { TaskCreateAdvancedSettings } from "@/components/task-create-dialog-advanced-settings"; import type { DialogFormBodyProps, TaskCreateDialogProps, @@ -111,13 +111,6 @@ function CreateModeBody(props: DialogFormBodyProps) { onComposerSubmit={props.onComposerSubmit} /> - {isCreateMode && !isTaskStarted && ( - - )} {props.bottomSlot} ); @@ -190,6 +183,13 @@ function DialogFormBody(props: DialogFormBodyProps) { agentProfiles={props.agentProfiles} workflowLocked={props.workflowLocked} /> +
); } diff --git a/apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts b/apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts new file mode 100644 index 0000000000..ed85d057de --- /dev/null +++ b/apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { KanbanPage } from "../../pages/kanban-page"; + +useRegularMode(); + +const DEPENDENCY_TRIGGER = "task-create-dependencies-trigger"; +const DEPENDENCY_OPTION_PREFIX = "task-create-dependency-option-"; + +function taskIdFromUrl(url: string): string { + const match = url.match(/\/t\/([^/?]+)/); + if (!match) throw new Error(`Task route missing from ${url}`); + return match[1]; +} + +test.describe("Task-create dependency selector", () => { + test("selects, clears, and persists multiple predecessor tasks", async ({ + testPage, + apiClient, + seedData, + }) => { + const extraWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Dependency selector workflow", + "simple", + ); + const taskOptions = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }; + const alpha = await apiClient.createTask(seedData.workspaceId, "Dependency Alpha", taskOptions); + const beta = await apiClient.createTask(seedData.workspaceId, "Dependency Beta", taskOptions); + const archived = await apiClient.createTask( + seedData.workspaceId, + "Archived dependency", + taskOptions, + ); + await apiClient.archiveTask(archived.id); + + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + const advancedSettings = dialog.getByTestId("task-create-advanced-settings"); + const advancedTrigger = advancedSettings.getByTestId("task-create-advanced-settings-trigger"); + await expect(advancedTrigger).toBeVisible(); + await expect(advancedTrigger).toHaveAttribute("aria-expanded", "false"); + await expect(dialog.getByTestId(DEPENDENCY_TRIGGER)).toHaveCount(0); + await expect(dialog.getByTestId("agent-profile-selector")).toBeVisible(); + await expect(dialog.getByTestId("executor-profile-selector")).toBeVisible(); + const workflowSelector = dialog.getByTestId("workflow-selector-trigger"); + await expect(workflowSelector).toBeVisible(); + expect( + await advancedSettings.evaluate((element) => { + const dialogRoot = element.closest('[data-testid="create-task-dialog"]'); + if (!dialogRoot) return false; + return [ + "agent-profile-selector", + "executor-profile-selector", + "workflow-selector-trigger", + ].every((testId) => { + const control = dialogRoot.querySelector(`[data-testid="${testId}"]`); + return ( + control !== null && + Boolean(control.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) + ); + }); + }), + ).toBe(true); + await advancedTrigger.click(); + await expect(advancedTrigger).toHaveAttribute("aria-expanded", "true"); + await expect( + advancedSettings.getByTestId("task-create-dependency-setting-label"), + ).toContainText("Depends on"); + const settingGrid = advancedSettings.getByTestId("task-create-advanced-settings-grid"); + const settingGridBox = await settingGrid.boundingBox(); + const settingRow = advancedSettings.getByTestId("task-create-dependency-setting-row"); + const settingRowBox = await settingRow.boundingBox(); + const settingLabel = advancedSettings.getByTestId("task-create-dependency-setting-label"); + const settingLabelBox = await settingLabel.boundingBox(); + const selectorContainer = advancedSettings.getByTestId( + "task-create-dependency-selector-container", + ); + const selectorContainerBox = await selectorContainer.boundingBox(); + expect(settingGridBox).not.toBeNull(); + expect(settingRowBox).not.toBeNull(); + expect(settingLabelBox).not.toBeNull(); + expect(selectorContainerBox).not.toBeNull(); + expect(settingRowBox!.width).toBeLessThan(settingGridBox!.width * 0.75); + expect(selectorContainerBox!.width).toBeLessThan(settingGridBox!.width * 0.75); + expect(selectorContainerBox!.x).toBeGreaterThan(settingLabelBox!.x + settingLabelBox!.width); + expect(Math.abs(selectorContainerBox!.y - settingLabelBox!.y)).toBeLessThanOrEqual(4); + const settingInfo = advancedSettings.getByTestId("task-create-dependency-setting-info"); + await settingInfo.hover(); + await expect(testPage.locator('[data-slot="tooltip-content"]:visible').last()).toContainText( + "This task waits until every selected task completes successfully.", + ); + + const dependency = dialog.getByTestId(DEPENDENCY_TRIGGER); + await expect(dependency).toBeVisible(); + await expect(dependency).toContainText("No dependency"); + await dependency.click(); + + const picker = dialog.getByTestId("task-create-dependencies-popover"); + await expect(picker).toBeVisible(); + const info = picker.getByTestId("task-create-dependency-info"); + await expect(info).toBeVisible(); + await info.hover(); + await expect(testPage.locator('[data-slot="tooltip-content"]:visible').last()).toContainText( + "This task waits until every selected task completes successfully.", + ); + + const search = picker.getByPlaceholder("Search tasks..."); + await search.fill("Dependency Alpha"); + const alphaOption = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${alpha.id}`); + await expect(alphaOption).toBeVisible(); + await expect(alphaOption.getByTestId("task-create-dependency-task-icon")).toBeVisible(); + await expect(picker.getByText("Archived dependency")).toHaveCount(0); + await alphaOption.click(); + await expect(dependency).toContainText("Dependency Alpha"); + + await search.fill("Dependency Beta"); + const betaOption = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${beta.id}`); + await expect(betaOption).toBeVisible(); + await betaOption.click(); + await expect(dependency).toContainText("2 dependencies"); + + await search.fill(""); + await picker.getByTestId("task-create-no-dependency").click(); + await expect(dependency).toContainText("No dependency"); + + await dependency.click(); + await search.fill("Dependency Alpha"); + await picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${alpha.id}`).click(); + await search.fill("Dependency Beta"); + await picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${beta.id}`).click(); + await testPage.keyboard.press("Escape"); + + await dialog.getByTestId("task-title-input").fill("Task with two dependencies"); + await dialog + .getByTestId("task-description-input") + .fill("Created from the dependency selector"); + await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 }); + await dialog.getByTestId("submit-start-agent-chevron").click(); + await testPage.getByTestId("submit-create-without-agent").click(); + await expect(dialog).not.toBeVisible({ timeout: 15_000 }); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const createdTask = await apiClient.getTask(taskIdFromUrl(testPage.url())); + const dependencies = await apiClient.getTaskDependencies(createdTask.id); + expect(dependencies.depends_on).toHaveLength(2); + expect(dependencies.depends_on?.map((dependencyRef) => dependencyRef.id)).toEqual( + expect.arrayContaining([alpha.id, beta.id]), + ); + } finally { + await apiClient.deleteWorkflow(extraWorkflow.id).catch(() => {}); + } + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts new file mode 100644 index 0000000000..05e9af609e --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "../../fixtures/test-base"; +import { + assertLocatorWithinViewportX, + assertNoDocumentHorizontalOverflow, + assertNoElementHorizontalOverflow, +} from "../../helpers/layout-assertions"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; + +useRegularMode(); + +test.describe("Create task dependency selector on mobile", () => { + test("keeps the selector contained and supports touch selection and help", async ({ + testPage, + apiClient, + seedData, + }) => { + const predecessor = await apiClient.createTask( + seedData.workspaceId, + "Mobile dependency predecessor with a long title", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await mobile.mobileFab.tap(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + const advancedTrigger = dialog.getByTestId("task-create-advanced-settings-trigger"); + const advancedTriggerBox = await advancedTrigger.boundingBox(); + expect(advancedTriggerBox).not.toBeNull(); + expect(advancedTriggerBox!.height).toBeGreaterThanOrEqual(44); + await assertLocatorWithinViewportX(advancedTrigger, "mobile advanced settings trigger"); + await expect(dialog.getByTestId("task-create-dependencies-trigger")).toHaveCount(0); + + await advancedTrigger.tap(); + await expect(advancedTrigger).toHaveAttribute("aria-expanded", "true"); + const settingLabel = dialog.getByTestId("task-create-dependency-setting-label"); + await expect(settingLabel).toContainText("Depends on"); + const settingRow = dialog.getByTestId("task-create-dependency-setting-row"); + const settingRowBox = await settingRow.boundingBox(); + const settingLabelBox = await settingLabel.boundingBox(); + const selectorContainer = dialog.getByTestId("task-create-dependency-selector-container"); + const selectorContainerBox = await selectorContainer.boundingBox(); + expect(settingRowBox).not.toBeNull(); + expect(settingLabelBox).not.toBeNull(); + expect(selectorContainerBox).not.toBeNull(); + expect(selectorContainerBox!.x).toBeGreaterThan(settingLabelBox!.x + settingLabelBox!.width); + expect(Math.abs(selectorContainerBox!.y - settingLabelBox!.y)).toBeLessThanOrEqual(4); + const settingInfo = dialog.getByTestId("task-create-dependency-setting-info"); + const settingInfoBox = await settingInfo.boundingBox(); + expect(settingInfoBox).not.toBeNull(); + expect(settingInfoBox!.height).toBeGreaterThanOrEqual(44); + expect(settingInfoBox!.width).toBeGreaterThanOrEqual(44); + await settingInfo.tap(); + await expect(testPage.locator('[data-slot="tooltip-content"]:visible').last()).toContainText( + "This task waits until every selected task completes successfully.", + ); + const trigger = dialog.getByTestId("task-create-dependencies-trigger"); + const triggerBox = await trigger.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(triggerBox!.height).toBeGreaterThanOrEqual(44); + await assertLocatorWithinViewportX(trigger, "mobile dependency trigger"); + await assertNoElementHorizontalOverflow(trigger, "mobile dependency trigger text"); + + await trigger.tap(); + const picker = dialog.getByTestId("task-create-dependencies-popover"); + await expect(picker).toBeVisible(); + await assertLocatorWithinViewportX(picker, "mobile dependency picker"); + await assertNoElementHorizontalOverflow(picker, "mobile dependency picker"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency picker"); + + const info = picker.getByTestId("task-create-dependency-info"); + const infoBox = await info.boundingBox(); + expect(infoBox).not.toBeNull(); + expect(infoBox!.height).toBeGreaterThanOrEqual(44); + expect(infoBox!.width).toBeGreaterThanOrEqual(44); + await info.tap(); + await expect(testPage.locator('[data-slot="tooltip-content"]:visible').last()).toContainText( + "This task waits until every selected task completes successfully.", + ); + + const option = picker.getByTestId(`task-create-dependency-option-${predecessor.id}`); + await expect(option).toBeVisible(); + await expect(option.getByTestId("task-create-dependency-task-icon")).toBeVisible(); + await option.tap(); + await expect(trigger).toContainText("Mobile dependency predecessor"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency selection"); + }); +}); diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index ce01c0b7b5..69acd45ca8 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -41,6 +41,7 @@ "addedRemoved": "{{added}} added · {{removed}} removed", "adding": "Adding…", "advanced": "Advanced", + "advancedSettings": "Advanced settings", "afterDuration": " after {{duration}}", "agent": "Agent", "agentBootFailedToResume": "Failed to resume agent {{agentName}}", @@ -340,6 +341,10 @@ "dependencyCountBlockedBy_other": "{{count}} blockers", "dependencyCountBlocks_one": "blocks {{count}}", "dependencyCountBlocks_other": "blocks {{count}}", + "dependencyCount_one": "{{count}} dependency", + "dependencyCount_other": "{{count}} dependencies", + "dependencyInfo": "This task waits until every selected task completes successfully. If you start an agent, it starts automatically when all dependencies are resolved.", + "dependencyInfoLabel": "About task dependencies", "dependsOn": "Depends on", "dependsOnWillAutoStart": "This task waits for the tasks above. If you start an agent, it runs automatically once every one of them completes successfully.", "describeWhatNeedsToChange": "Describe what needs to change", @@ -794,6 +799,7 @@ "noContent": "No content", "noDecisionYet": "No decision yet", "noDependencies": "No dependencies", + "noDependency": "No dependency", "noDocumentsFound": "No documents found.", "noDocumentsYet": "No documents yet.", "noEditorsAvailable": "No editors available", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 4385211214..ed817e2b47 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -41,6 +41,7 @@ "addedRemoved": "{{added}} àďďēď · {{removed}} ŕēḿōvēď", "adding": "Àďďĩńĝ…", "advanced": "Àďvàńćēď", + "advancedSettings": "Àďvàńćēď śēţţĩńĝś", "afterDuration": " àƒţēŕ {{duration}}", "agent": "Àĝēńţ", "agentBootFailedToResume": "Ƒàĩĺēď ţō ŕēśũḿē àĝēńţ {{agentName}}", @@ -340,6 +341,10 @@ "dependencyCountBlockedBy_other": "{{count}} ƀĺōćķēŕś", "dependencyCountBlocks_one": "ƀĺōćķś {{count}}", "dependencyCountBlocks_other": "ƀĺōćķś {{count}}", + "dependencyCount_one": "{{count}} ďēƥēńďēńćŷ", + "dependencyCount_other": "{{count}} ďēƥēńďēńćĩēś", + "dependencyInfo": "Ţĥĩś ţàśķ ŵàĩţś ũńţĩĺ ēvēŕŷ śēĺēćţēď ţàśķ ćōḿƥĺēţēś śũććēśśƒũĺĺŷ. Ĩƒ ŷōũ śţàŕţ àń àĝēńţ, ĩţ śţàŕţś àũţōḿàţĩćàĺĺŷ ŵĥēń àĺĺ ďēƥēńďēńćĩēś àŕē ŕēśōĺvēď.", + "dependencyInfoLabel": "Àƀōũţ ţàśķ ďēƥēńďēńćĩēś", "dependsOn": "Ďēƥēńďś ōń", "dependsOnWillAutoStart": "Ţĥĩś ţàśķ ŵàĩţś ƒōŕ ţĥē ţàśķś àƀōvē. Ĩƒ ŷōũ śţàŕţ àń àĝēńţ, ĩţ ŕũńś àũţōḿàţĩćàĺĺŷ ōńćē ēvēŕŷ ōńē ōƒ ţĥēḿ ćōḿƥĺēţēś śũććēśśƒũĺĺŷ.", "describeWhatNeedsToChange": "Ďēśćŕĩƀē ŵĥàţ ńēēďś ţō ćĥàńĝē", @@ -794,6 +799,7 @@ "noContent": "Ńō ćōńţēńţ", "noDecisionYet": "Ńō ďēćĩśĩōń ŷēţ", "noDependencies": "Ńō ďēƥēńďēńćĩēś", + "noDependency": "Ńō ďēƥēńďēńćŷ", "noDocumentsFound": "Ńō ďōćũḿēńţś ƒōũńď.", "noDocumentsYet": "Ńō ďōćũḿēńţś ŷēţ.", "noEditorsAvailable": "Ńō ēďĩţōŕś àvàĩĺàƀĺē", diff --git a/docs/plans/task-create-advanced-settings/plan.md b/docs/plans/task-create-advanced-settings/plan.md new file mode 100644 index 0000000000..5c24f4747e --- /dev/null +++ b/docs/plans/task-create-advanced-settings/plan.md @@ -0,0 +1,133 @@ +--- +spec: docs/specs/task-dependencies/create-dialog-advanced-settings.md +created: 2026-08-13 +status: implemented +--- + +# Implementation Plan: Task-create advanced settings disclosure + +## Overview + +Wrap the existing task-create dependency selector in a compact, collapsed +advanced-settings disclosure. Keep dependency state, selector behavior, and +the `blocked_by` payload unchanged while reducing the default form height and +leaving a contained region for future uncommon create options. + +This is a frontend-only presentation change. It builds on the implemented +dependency selector refinement in +`docs/specs/task-dependencies/create-dialog-dependency-selector.md` and does +not change the backend, API client, persistence, or WebSocket contracts. + +## Frontend composition + +### Advanced settings component + +- Add a focused `TaskCreateAdvancedSettings` component in + `apps/web/components/task-create-dialog-advanced-settings.tsx`. +- Use the existing `@kandev/ui/collapsible` primitive. Keep the disclosure + uncontrolled or locally controlled with a collapsed initial value so its + open state is presentation-only and does not enter `DialogFormState`. +- Render a semantic trigger with localized `Advanced settings` copy, muted + styling, a very subtle text size, a direction indicator, `aria-expanded`, + and a mobile hitbox of at least 44 CSS pixels. +- Render the existing `TaskCreateDependencies` in a responsive option grid + with the localized `Depends on` label and contextual help to the left of the + selector in the same column. Use two columns on desktop so future advanced options can + share a row, and collapse to one column on narrow screens. Keep the content + wrapper ready for future sibling controls without introducing a speculative + registry or new state shape. +- Preserve the dependency selector's `value`, `onChange`, and disabled props. + +### Dialog placement + +- Update `apps/web/components/task-create-dialog.tsx` so the advanced section + renders at the bottom of the create-task form, below the model, executor, and + workflow selector controls. +- Keep `renderWorkflowSection` and all workflow visibility, locking, and + single-workflow override behavior unchanged. Refactor the old paired + workflow/dependency wrapper into a workflow-only render path if needed, then + mount the advanced section where the dependency slot was previously shown. +- Keep the advanced component out of session mode, edit mode, and started-task + rendering. +- Keep the dialog body as the only outer form scroll owner. Do not add a nested + scrolling container around the disclosure. + +### Localization + +- Add the new trigger label to + `apps/web/src/locales/en/task.json`. +- Regenerate or update `apps/web/src/locales/pseudo/task.json` using the + repository's i18n tooling. +- Add other locale values only through the repository's established catalog + workflow. Do not hardcode the label in JSX or tests that exercise translated + rendering. + +## Tests + +### Component tests + +- Add `apps/web/components/task-create-dialog-advanced-settings.test.tsx` for + the default collapsed state, expanded state, semantic attributes, touch-safe + trigger class, dependency label/help, dependency mounting, and state + preservation across collapse and reopen. +- Update + `apps/web/components/task-create-dialog-form-body.test.tsx` to cover the + workflow-only path and ensure the advanced dependency section does not remove + workflow visibility rules. +- Keep the existing + `apps/web/components/task-create-dialog-dependencies.test.tsx` coverage + green. It remains the source of truth for selector internals. +- Preserve existing payload tests for multiple `blockedBy` IDs and the empty + array case. + +## E2E tests + +- Extend `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` to + assert that the create dialog starts with a collapsed advanced row below the + model, executor, and workflow controls, the dependency trigger is hidden + until expansion, the labeled setting help is available, and the existing + selection and create-payload behavior remains intact. +- Extend `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` + to tap the advanced row before opening the dependency selector, check the + row's touch box and expanded state, and retain picker containment, help, + touch selection, and horizontal-overflow assertions. +- Use existing managed `chromium` and `mobile-chrome` projects. No container + or backend behavior is required for this presentation change beyond the + existing task-create fixture. + +## Implementation waves + +Wave 1 (sequential): + +- [x] [Task 01: Add the advanced settings disclosure](task-01-advanced-settings-ui.md) + +Wave 2 (sequential, depends on Task 01): + +- [x] [Task 02: Verify disclosure behavior on desktop and mobile](task-02-advanced-settings-e2e.md) + +No task is parallel-safe because the browser selectors and placement depend on +the final component composition. + +## Verification commands + +The implementation task owns focused component, typecheck, lint, i18n, and +diff checks. The E2E task owns the managed desktop and mobile browser runs. +The exact commands are recorded in the individual task files. + +## Open questions + +None. The disclosure is intentionally limited to presentation state, and the +existing dependency selector remains the only advanced option in this change. + +## Verification results + +- Component tests: 40 tests passed across the advanced-settings, form-body, + dependency-selector, and helper suites. +- TypeScript typecheck, focused ESLint, `i18n:check`, `i18n:ratchet`, and + `git diff --check` passed. `i18n:check` still reports the repository's + existing advisory pt-pt and zh-cn catalog parity findings. +- Managed Chromium E2E passed for collapsed-by-default rendering, expansion, + dependency selection, clearing, help, search, and persistence. +- Managed mobile-chrome E2E passed for touch expansion, the 44 CSS pixel + disclosure hitbox, picker containment, help, selection, and horizontal + overflow. diff --git a/docs/plans/task-create-advanced-settings/task-01-advanced-settings-ui.md b/docs/plans/task-create-advanced-settings/task-01-advanced-settings-ui.md new file mode 100644 index 0000000000..73c2b90517 --- /dev/null +++ b/docs/plans/task-create-advanced-settings/task-01-advanced-settings-ui.md @@ -0,0 +1,113 @@ +--- +id: "01-advanced-settings-ui" +title: "Add the advanced settings disclosure" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/task-dependencies/create-dialog-advanced-settings.md" +--- + +# Task 01: Add the advanced settings disclosure + +Implement the compact collapsed disclosure around the existing task-create +dependency selector. Preserve the current workflow behavior, dependency state, +selector semantics, and payload path. + +## Acceptance + +- A create-mode, unstarted task dialog shows a localized, muted, very small + `Advanced settings` trigger at the bottom of the form, below the model, + executor, and workflow selector controls, and the trigger is collapsed by + default. +- The trigger is a semantic collapsible control with `aria-expanded`, a stable + test identity, and a mobile hitbox of at least 44 CSS pixels. +- The dependency selector is absent while collapsed and appears inside the + expanded content with the existing `No dependency` default, icon, picker, + task rows, info help, and multi-selection behavior. The expanded content uses + a two-column desktop option grid, with the localized `Depends on` label and + contextual help to the left of the selector in the same column. The grid + collapses to one column on narrow screens. +- Collapsing and reopening the section does not clear selected predecessor IDs. +- Workflow visibility, workflow locking, agent/executor behavior, and the + existing `blockedBy` to `blocked_by` payload path remain unchanged. +- Session mode, edit mode, and started-task forms do not gain the disclosure. +- New copy is localized in the task namespace, and English plus pseudo catalogs + pass the repository checks. +- Component tests cover collapsed and expanded rendering, semantic state, + touch-safe sizing, selector mounting, and selection persistence. + +## TDD sequence + +1. Add failing component tests for collapsed state, expansion, and persistence. +2. Add the disclosure component and wire it into the existing dependency area. +3. Refactor workflow rendering only as needed to keep its behavior independent + from the dependency presentation. +4. Add localized copy, run focused tests, then clean up styling and test IDs. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- task-create-dialog-advanced-settings task-create-dialog-form-body task-create-dialog-dependencies task-create-dialog-helpers +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web exec eslint --max-warnings 0 components/task-create-dialog-advanced-settings.tsx components/task-create-dialog-advanced-settings.test.tsx components/task-create-dialog-form-body.tsx components/task-create-dialog-form-body.test.tsx components/task-create-dialog.tsx +pnpm --filter @kandev/web run i18n:check +pnpm --filter @kandev/web run i18n:ratchet +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task-create-dialog-advanced-settings.tsx` +- `apps/web/components/task-create-dialog-advanced-settings.test.tsx` +- `apps/web/components/task-create-dialog-form-body.tsx` +- `apps/web/components/task-create-dialog-form-body.test.tsx` +- `apps/web/components/task-create-dialog.tsx` +- `apps/web/src/locales/en/task.json` +- `apps/web/src/locales/pseudo/task.json` + +## Dependencies and risks + +None. This task depends on the implemented dependency selector and should not +change its API. The main risk is accidentally moving or hiding the workflow +selector while extracting the old paired row, so the workflow visibility tests +must remain explicit. + +## Parallelism + +`sequential`. The disclosure placement and component contract define the E2E +selectors used by Task 02. + +## Inputs + +- Spec: `docs/specs/task-dependencies/create-dialog-advanced-settings.md` +- Plan: `docs/plans/task-create-advanced-settings/plan.md` +- Existing dependency behavior in + `apps/web/components/task-create-dialog-dependencies.tsx` +- Existing workflow rendering in + `apps/web/components/task-create-dialog-form-body.tsx` +- Existing mobile picker and touch-target expectations in the implemented + dependency selector tests + +## Output contract + +Report the component and dialog files changed, focused test results, i18n and +typecheck results, any placement or accessibility risks, and the updated task +and plan status before starting Task 02. + +## Results + +- Added `TaskCreateAdvancedSettings` as an inline, collapsed disclosure below + the model, executor, and workflow controls. It keeps the dependency selector + inside an extensible content region, labels the setting with contextual help, + and preserves `blockedBy` state and disabled behavior. +- Kept workflow rendering independent and unchanged for its existing visibility, + locking, and single-workflow rules. +- Added localized English and pseudo-locale copy plus component coverage for + collapsed and expanded semantics, the mobile-safe hitbox, dependency + mounting, state persistence, and unavailable modes. +- Focused component tests passed: 40 tests. Typecheck, focused ESLint, i18n + checks, and `git diff --check` passed. No remaining placement or accessibility + risk was found after the managed mobile run validated the 44 CSS pixel target. diff --git a/docs/plans/task-create-advanced-settings/task-02-advanced-settings-e2e.md b/docs/plans/task-create-advanced-settings/task-02-advanced-settings-e2e.md new file mode 100644 index 0000000000..67a435cba8 --- /dev/null +++ b/docs/plans/task-create-advanced-settings/task-02-advanced-settings-e2e.md @@ -0,0 +1,85 @@ +--- +id: "02-advanced-settings-e2e" +title: "Verify disclosure behavior on desktop and mobile" +status: completed +wave: 2 +depends_on: ["01-advanced-settings-ui"] +plan: "plan.md" +spec: "../../specs/task-dependencies/create-dialog-advanced-settings.md" +--- + +# Task 02: Verify disclosure behavior on desktop and mobile + +Extend the existing dependency-selector browser coverage to prove the new +collapsed default, expansion path, mobile touch behavior, and unchanged task +creation payload. + +## Acceptance + +- Desktop E2E proves the advanced row is collapsed on first render below the + model, executor, and workflow controls, the dependency trigger is hidden + until expansion, and the selector appears after expansion. +- Desktop E2E keeps the existing selector checks for the `No dependency` + default, labeled setting help, task icons, search, info help, multiple + selection, clearing, and `blocked_by` persistence after creation. It also + verifies the two-column option grid keeps the label to the left of the selector + on the same horizontal row and makes the option narrower than the full desktop grid. +- Mobile E2E expands the section with touch input, verifies the disclosure hit + box and setting help control are at least 44 CSS pixels, verifies the label + and selector remain on the same horizontal row with the label to the selector's + left, and retains picker containment, touch + selection, help readability, and no-horizontal-overflow checks. +- The managed desktop and mobile runs pass against a fresh production build + with no stale frontend artifacts. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +cd web +pnpm e2e:run --project chromium e2e/tests/task/create-task-dependency-selector.spec.ts +pnpm e2e:run --project mobile-chrome e2e/tests/task/mobile-create-task-dependency-selector.spec.ts +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` +- `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` + +## Dependencies and risks + +Task 01. The exact disclosure test ID and final placement are outputs of the UI +implementation. If the mobile hitbox is reduced because the visual label is +small, the E2E test should catch it before the change is considered complete. + +## Parallelism + +`sequential` after Task 01. The E2E files are independent of backend changes, +but they depend on the final UI contract. + +## Inputs + +- Scenarios and mobile contract in + `docs/specs/task-dependencies/create-dialog-advanced-settings.md` +- Existing desktop coverage in + `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` +- Existing mobile coverage in + `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` +- Layout helpers in `apps/web/e2e/helpers/layout-assertions.ts` + +## Output contract + +Report the exact managed commands, pass counts, viewport and overflow results, +artifacts, blockers, and the final Task 02 and plan status. + +## Results + +- `pnpm e2e:run --project chromium e2e/tests/task/create-task-dependency-selector.spec.ts` + passed 1 test on a fresh production build. +- `pnpm e2e:run --project mobile-chrome e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` + passed 1 test on a fresh production build. The test covered the 44 CSS pixel + disclosure hitbox, picker containment, touch selection, help readability, + and no horizontal overflow. +- No E2E artifacts or blockers remain. The runner cleaned its temporary fixture + and test-result directories after each managed run. diff --git a/docs/plans/task-create-dependency-selector/plan.md b/docs/plans/task-create-dependency-selector/plan.md new file mode 100644 index 0000000000..5b5f6783c6 --- /dev/null +++ b/docs/plans/task-create-dependency-selector/plan.md @@ -0,0 +1,151 @@ +--- +spec: docs/specs/task-dependencies/create-dialog-dependency-selector.md +created: 2026-08-13 +status: implemented +--- + +# Implementation Plan: Task-create dependency selector refinement + +## Overview + +Replace the task-create dialog's separate dependency action and chips with one +searchable, multi-select control that matches the agent and executor selector +visual language. Pair it with the workflow selector on desktop and keep the +same data derivation and `blocked_by` submission path. Add focused component +tests plus desktop and mobile browser coverage for selection, teaching copy, +placement, and viewport containment. + +This is a frontend-only refinement. It does not change dependency persistence, +the task API, or the existing `blockedBy` state shape. + +## Frontend + +### Dependency selector + +- `apps/web/components/task-create-dialog-dependencies.tsx` + - Replace the separate label, Add dependency button, and removable chips with + one full-width selector trigger. + - Preserve `useBoardTasks`, archived-task filtering, and both board-slice + sources. + - Keep multi-predecessor toggling in `blockedBy`, add the `No dependency` + clearing entry, task icons, selected-state indicators, and localized + trigger summaries. + - Add the search-row info action with an accessible tooltip/help message and + stable test IDs for the trigger, dropdown, task entries, and help control. + +### Workflow/dependency layout + +- `apps/web/components/task-create-dialog.tsx` + - Stop rendering the dependency control as an independent block below the + agent and executor row. +- `apps/web/components/task-create-dialog-form-body.tsx` + - Render the workflow and dependency controls in a shared responsive row. + - Keep the dependency selector visible when the workflow selector is hidden + for a single enforced workflow or a locked workflow. + - Stack workflow first and dependency second below the mobile breakpoint. + +### Localization + +- `apps/web/src/locales/en/task.json` +- `apps/web/src/locales/pseudo/task.json` (regenerated with the repository + pseudo-locale script) + +Add the default label, search placeholder, empty result, dependency count, and +help text. Keep all copy in the task namespace and remove or leave legacy keys +only after checking for other consumers. + +## State and API + +No state shape, API client, backend, persistence, or WebSocket changes. The +selector continues to call `onChange` with the complete predecessor ID array, +and `buildCreateTaskPayload` continues to emit `blocked_by` only when the array +is non-empty. + +## Tests + +- **What:** The dependency selector shows its no-dependency default, toggles + several task entries, clears all entries, excludes archived tasks, renders + task icons, and exposes the info help control. + **File:** `apps/web/components/task-create-dialog-dependencies.test.tsx` + **How:** Render the component with mocked Kanban board and snapshot state; + interact with the command menu and assert `onChange` values and visible + labels/test IDs. +- **What:** Workflow and dependency controls share the desktop row while the + dependency control remains visible without a workflow trigger. + **File:** `apps/web/components/task-create-dialog-form-body.test.tsx` + **How:** Render `WorkflowSection` or the extracted paired row with both a + visible workflow and a single enforced workflow; assert the dependency slot + remains present and ordering is stable. +- **What:** Existing create payload behavior remains intact for multiple + predecessors. + **File:** `apps/web/components/task-create-dialog-helpers.test.ts` + **How:** Retain and, if needed, extend the pure payload test for two + `blockedBy` IDs and the omitted empty-array case. + +## E2E Tests + +- **Scenario:** GIVEN two non-archived predecessor tasks, WHEN the user opens + the desktop create dialog, searches for both, and toggles both entries, THEN + the single dependency trigger shows the selected count and the created task + reports both predecessor IDs. + **File:** `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` + **What to verify:** Default `No dependency` state, workflow/dependency row, + task icons, search, info help, multi-selection, and `blocked_by` persistence + after UI submission. +- **Scenario:** GIVEN a Pixel 5 viewport, WHEN the user opens the task-create + dialog and dependency picker, THEN the selector and help action are + touch-usable, the picker is contained, and the document has no horizontal + overflow. + **File:** `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` + **What to verify:** Full-width mobile trigger, `.tap()` selection, readable + help content, picker containment, 44px active controls, and overflow safety. + +## Mobile parity contract + +- Desktop outcome: choose zero or more predecessor tasks while creating a task. +- Mobile entry point: the dependency selector in the create dialog's form, + immediately after the workflow context. +- Closest exemplar: mobile task-create repository picker / `Pill` command + surface for contained search, touch targets, and overflow checks. +- Mobile primary action: open the dependency selector and toggle task rows. +- Presentation: one full-width trigger and a contained searchable popover; no + desktop-only hover path is required for selecting a dependency. +- Scroll ownership: the command list owns long candidate scrolling; the dialog + body owns form scrolling when the popover is closed. +- Shared logic: task candidates, archived filtering, selected IDs, and payload + state are shared between desktop and mobile. + +## Verification Results + +- Focused frontend unit tests passed: `pnpm --filter @kandev/web test -- + task-create-dialog-dependencies task-create-dialog-form-body + task-create-dialog-helpers` (4 files, 62 tests). +- Frontend typecheck passed: `pnpm run typecheck` from `apps/web`. +- Focused frontend and E2E-file ESLint passed with `--max-warnings 0`. +- `pnpm run i18n:check` passed with the repository's existing real-locale + parity advisories; `pnpm run i18n:ratchet` passed with zero new violations. +- `git diff --check` passed. +- Desktop managed E2E passed: `pnpm e2e:run --project chromium + e2e/tests/task/create-task-dependency-selector.spec.ts` (1 test). +- Mobile managed E2E passed: `pnpm e2e:run --project mobile-chrome + e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` (1 test). +- The first mobile run found a 43.56px rem-based trigger; the final 48px + mobile control sizing and rerun passed. + +## Implementation Waves And Parallel Candidates + +Wave 1 (sequential): + +- [x] [Task 01: Implement the selector and layout](task-01-selector-ui.md) + +Wave 2 (sequential, depends on Task 01): + +- [x] [Task 02: Verify desktop and mobile flows](task-02-selector-e2e.md) + +No task is parallel-safe because the browser coverage depends on the final +selector test IDs and responsive composition. + +## Open Questions + +None. The selector preserves multiple predecessor selection after user +confirmation. diff --git a/docs/plans/task-create-dependency-selector/task-01-selector-ui.md b/docs/plans/task-create-dependency-selector/task-01-selector-ui.md new file mode 100644 index 0000000000..6e443272d4 --- /dev/null +++ b/docs/plans/task-create-dependency-selector/task-01-selector-ui.md @@ -0,0 +1,103 @@ +--- +id: "01-selector-ui" +title: "Implement the selector and layout" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/task-dependencies/create-dialog-dependency-selector.md" +--- + +# Task 01: Implement the selector and layout + +Implement the searchable multi-select dependency control and pair it with the +workflow selector while preserving the existing `blockedBy` state and payload. + +## Acceptance + +- The create dialog shows one dependency selector with the `No dependency` + default, task icons, archived-task filtering, multiple selection, clear-all + behavior, and an info help control beside the search field. +- The workflow and dependency controls share a desktop row and stack in the + intended order on mobile. The dependency selector remains present when the + workflow picker is hidden or locked. +- Component tests cover selection, clearing, task candidates, help copy, and + layout visibility. New copy is localized in English and pseudo catalogs, and + the existing `blocked_by` payload tests remain green. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- task-create-dialog-dependencies task-create-dialog-form-body task-create-dialog-helpers +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web lint +pnpm --filter @kandev/web run i18n:check +pnpm --filter @kandev/web run i18n:ratchet +``` + +## Files likely touched + +- `apps/web/components/task-create-dialog-dependencies.tsx` +- `apps/web/components/task-create-dialog.tsx` +- `apps/web/components/task-create-dialog-form-body.tsx` +- `apps/web/components/task-create-dialog-dependencies.test.tsx` +- `apps/web/components/task-create-dialog-form-body.test.tsx` +- `apps/web/components/task-create-dialog-helpers.test.ts` +- `apps/web/src/locales/en/task.json` +- `apps/web/src/locales/pseudo/task.json` + +## Dependencies + +None. + +## Parallelism + +`sequential`. The layout and selector share the same dialog composition and +must be verified together. + +## Inputs + +- Spec: `docs/specs/task-dependencies/create-dialog-dependency-selector.md` +- Plan: `docs/plans/task-create-dependency-selector/plan.md`, Frontend and + Mobile parity sections +- Existing candidate derivation and payload behavior in + `task-create-dialog-dependencies.tsx` and + `task-create-dialog-helpers.ts` +- Existing selector and mobile picker patterns in `combobox.tsx`, + `task-create-dialog-selectors.tsx`, and `task-create-dialog-pill.tsx` + +## Results + +- Implemented the full-width searchable multi-select selector with a `No + dependency` trigger, dependency icon, task icons, archived-task filtering, + clear-all entry, selected-count summaries, and an accessible tooltip beside + the search field. +- Moved workflow and dependency controls into a responsive row. The workflow + selector renders first on desktop, the dependency selector renders second, + and dependencies remain visible when a single or locked workflow hides the + workflow picker. +- Preserved the existing `blockedBy` state and `blocked_by` payload behavior. +- Added `apps/web/components/task-create-dialog-dependencies.test.tsx` and + paired-row coverage in `task-create-dialog-form-body.test.tsx`. +- Updated English and generated pseudo-locale task copy. + +Verification: + +```text +pnpm --filter @kandev/web test -- task-create-dialog-dependencies task-create-dialog-form-body task-create-dialog-helpers: pass, 62 tests +pnpm --filter @kandev/web run typecheck: pass +pnpm exec eslint --max-warnings 0 components/task-create-dialog-dependencies.tsx components/task-create-dialog-dependencies.test.tsx components/task-create-dialog-form-body.tsx components/task-create-dialog-form-body.test.tsx components/task-create-dialog.tsx: pass +pnpm run i18n:check: pass; existing advisory real-locale parity warnings remain +pnpm run i18n:ratchet: pass +git diff --check: pass +``` + +Remaining risk: browser coverage still needs to verify the final selector +against the managed desktop and mobile runners. + +## Output contract + +Report the implementation summary, files changed, tests run, blockers, and +task/plan status updates in the same conversation. diff --git a/docs/plans/task-create-dependency-selector/task-02-selector-e2e.md b/docs/plans/task-create-dependency-selector/task-02-selector-e2e.md new file mode 100644 index 0000000000..f86e35751c --- /dev/null +++ b/docs/plans/task-create-dependency-selector/task-02-selector-e2e.md @@ -0,0 +1,82 @@ +--- +id: "02-selector-e2e" +title: "Verify desktop and mobile flows" +status: done +wave: 2 +depends_on: ["01-selector-ui"] +plan: "plan.md" +spec: "../../specs/task-dependencies/create-dialog-dependency-selector.md" +--- + +# Task 02: Verify desktop and mobile flows + +Add browser coverage for the final dependency selector through the real task +creation dialog and verify the mobile composition with the configured +`mobile-chrome` project. + +## Acceptance + +- Desktop E2E proves the default state, workflow/dependency placement, task + icons, search, info help, multiple selection, clearing, and creation with + the selected `blocked_by` IDs. +- Mobile E2E uses touch interaction to select a predecessor, verifies the help + action is reachable, checks picker and control containment, and proves there + is no document-level horizontal overflow. +- The exact managed desktop and mobile E2E commands pass against a fresh + production build with no stale frontend artifacts. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +cd web +pnpm e2e:run --project chromium e2e/tests/task/create-task-dependency-selector.spec.ts +pnpm e2e:run --project mobile-chrome e2e/tests/task/mobile-create-task-dependency-selector.spec.ts +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` +- `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` + +## Dependencies + +Task 01. The E2E selectors and responsive composition are outputs of the UI +implementation. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec scenarios and Mobile design contract in + `docs/specs/task-dependencies/create-dialog-dependency-selector.md` +- Plan E2E Tests and Mobile parity contract in `plan.md` +- `apps/web/e2e/fixtures/test-base.ts`, `pages/kanban-page.ts`, and + `pages/mobile-kanban-page.ts` +- Existing task creation coverage in `apps/web/e2e/tests/task/create-task.spec.ts` + and mobile repository picker coverage + +## Results + +- Desktop managed E2E passed: `pnpm e2e:run --project chromium + e2e/tests/task/create-task-dependency-selector.spec.ts` (1 test passed in + 4.6s). The run rebuilt the backend and pseudo-locale Vite assets, and the + task creation API assertion confirmed both selected predecessor IDs. +- Mobile managed E2E passed: `pnpm e2e:run --project mobile-chrome + e2e/tests/task/mobile-create-task-dependency-selector.spec.ts` (1 test + passed in 3.8s). The run rebuilt the backend and pseudo-locale Vite assets, + and verified the mobile selector, contained picker, touch selection, help + copy, and horizontal overflow contract. +- The first mobile run exposed a 43.56px rem-based trigger in the configured + viewport. Mobile controls now use the 48px spacing step, with desktop + controls retaining their compact sizes. The rerun passed. +- The managed runner cleaned its test results and PR asset directories before + each run. No failure artifacts remain from the passing reruns. + +## Output contract + +Report the E2E scenarios, commands, outcomes, artifacts, blockers, and +task/plan status updates in the same conversation. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 334f23c39a..c00d1a698c 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -81,6 +81,8 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [subtask-reparenting-drag-drop](tasks/subtask-reparenting-drag-drop.md) | building | | [subtask-completion-trigger](tasks/subtask-completion-trigger.md) | draft | | [task-dependencies](task-dependencies/spec.md) | draft | +| [task-dependencies - create dialog selector](task-dependencies/create-dialog-dependency-selector.md) | implemented | +| [task-dependencies - create dialog advanced settings](task-dependencies/create-dialog-advanced-settings.md) | shipped | | [subtree-controls](tasks/subtree-controls.md) | shipped | | [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft | | [runtime-cleanup](tasks/runtime-cleanup.md) | draft | diff --git a/docs/specs/task-dependencies/create-dialog-advanced-settings.md b/docs/specs/task-dependencies/create-dialog-advanced-settings.md new file mode 100644 index 0000000000..99a3a5f448 --- /dev/null +++ b/docs/specs/task-dependencies/create-dialog-advanced-settings.md @@ -0,0 +1,135 @@ +--- +status: shipped +created: 2026-08-13 +owner: kandev +--- + +# Task-create advanced settings disclosure + +## Why + +The searchable dependency selector is useful, but it is not needed for most +new tasks. Keeping it visible in the main form adds a full selector row to a +dialog whose primary job is to collect the task request and workspace context. +The create dialog should keep the common path compact while preserving a clear +place for dependency selection and future less-common options. + +## What + +- Add a compact, collapsed `Advanced settings` disclosure to the bottom of the + create-task form, below the model, executor, and workflow selector controls. +- Render the disclosure label in muted, very small text with a subtle chevron. + The whole row remains a semantic button so the small visual label does not + reduce the interaction target. +- Start the disclosure collapsed for a new create-dialog instance. Expanding + it reveals the existing dependency selector. The selector keeps its current + `No dependency` default, task icon treatment, search, help action, and + multiple-predecessor behavior. +- Show the dependency selector with a muted `Depends on` label and a contextual + help control that explains the dependency wait and automatic-start behavior. +- Keep the label and selector in the same setting column. On desktop, render + advanced settings as a two-column option grid so each row can hold two + settings; on narrow screens, collapse the grid to one column. +- Keep the dependency selector's `blockedBy` state and disabled behavior + unchanged. Collapsing the section must not clear a selected dependency, and + reopening the section must show the same selector value. +- Keep the existing workflow selector and its visibility rules unchanged. The + new disclosure replaces only the dependency's always-visible presentation. +- Make the expanded content a single extensible advanced-options region. Future + advanced controls can be appended inside that region without adding another + top-level row to the dialog. Do not add placeholder future controls in this + change. +- Show the disclosure only for an unstarted task in create mode, matching the + dependency selector's existing availability. Session mode, edit mode, and + started-task forms do not gain a new advanced-settings row. +- Keep all new trigger copy localized through the task translation namespace. + +## Interaction contract + +### Collapsed state + +- The row is visible at the bottom of the create-task settings, below the model, + executor, and workflow selector controls. +- The row shows the localized `Advanced settings` label in a muted, subtle + size and a direction indicator. +- The dependency trigger and its `No dependency` text are not visible while + the section is collapsed. +- The row exposes `aria-expanded="false"` and a stable control/test identity. + +### Expanded state + +- Activating the row with pointer, touch, Enter, or Space expands the content. +- The dependency selector is visible inside the content with its existing + searchable picker behavior. Its trigger uses the available row width on + narrow screens and a compact, constrained width on desktop. +- The selector is introduced by a localized `Depends on` label with an + accessible help control. Hovering or focusing the control explains the + dependency wait and automatic-start behavior. +- The row exposes `aria-expanded="true"`, and the expanded content is connected + to it through the normal collapsible relationship. +- Closing the section preserves every selected predecessor. Reopening it + reveals the existing selected title or localized count. +- The selector's search-row info control continues to explain dependency wait + behavior without changing the disclosure state or selection. + +## Mobile design contract + +- Desktop outcome: the create dialog keeps the workspace or worktree context, + then provides a compact advanced-settings entry that reveals dependencies. +- Mobile entry point: the same inline disclosure in the full-screen task-create + form. A new drawer or separate mobile navigation surface is not needed for + one short advanced field. +- The nearest shipped mobile exemplars are the task-create selector controls, + `task-create-dialog-pill.tsx`, and the existing dependency picker. Reuse the + project's contained popover and command-list behavior. +- The disclosure trigger has a mobile hitbox of at least 44 CSS pixels even + though its label uses a subtle text size. The expanded dependency trigger and + dependency help control and task rows retain their existing touch-safe sizing. +- The task-create form remains the outer scroll owner when the collapsible is + closed or expanded. The dependency command list remains the inner scroll + owner while its picker is open. +- The expanded content uses a responsive option grid. On desktop, each row can + contain two settings, and each setting keeps its label/help to the left of its + selector in the same column. On narrow screens, the grid collapses to one + column and must not introduce document-level horizontal overflow. +- State, selection logic, candidate filtering, and payload behavior are shared + across desktop and mobile. Only the disclosure geometry and touch + presentation are responsive. + +## Scenarios + +- **GIVEN** a new task create dialog, **WHEN** the form first renders, **THEN** + the advanced-settings row is visible and collapsed, and the dependency + trigger is not visible. +- **GIVEN** the collapsed advanced-settings row, **WHEN** the user activates it, + **THEN** the row reports expanded and the dependency selector appears below + the model, executor, and workflow selector controls. +- **GIVEN** the expanded selector with no predecessors, **THEN** its trigger + shows the existing `No dependency` label and dependency icon. +- **GIVEN** a selected predecessor, **WHEN** the user collapses and reopens the + advanced section, **THEN** the selected task title or localized dependency + count remains visible in the selector and the form state is unchanged. +- **GIVEN** a workflow selector that is visible, hidden for a single workflow, + or locked by a caller, **WHEN** the create dialog renders, **THEN** the + workflow's existing behavior is preserved and the advanced dependency + control remains available under the same create-mode rules. +- **GIVEN** session mode, edit mode, or a started task, **WHEN** the form + renders, **THEN** no advanced-settings disclosure is added. +- **GIVEN** a mobile create dialog, **WHEN** the user taps the disclosure and + opens the dependency picker, **THEN** the controls remain reachable, the + picker is contained in the viewport, and the document has no horizontal + overflow. +- **GIVEN** selected predecessors, **WHEN** the user submits the task after + using the disclosure, **THEN** the existing `blocked_by` payload contains the + same IDs and no dependency API contract changes. + +## Out of scope + +- Changing dependency persistence, AND semantics, candidate derivation, + archived-task filtering, or automatic task-start behavior. +- Changing the dependency selector's search, task icons, info help, or picker + interaction except where needed to mount it inside the disclosure. +- Adding dependency editing to task detail, Kanban card menus, or MCP. +- Adding or designing future advanced controls beyond the extensible content + region. +- Changing workflow, agent, executor, repository, or branch selector behavior. diff --git a/docs/specs/task-dependencies/create-dialog-dependency-selector.md b/docs/specs/task-dependencies/create-dialog-dependency-selector.md new file mode 100644 index 0000000000..4fd6c34c4d --- /dev/null +++ b/docs/specs/task-dependencies/create-dialog-dependency-selector.md @@ -0,0 +1,105 @@ +--- +status: implemented +created: 2026-08-13 +owner: kandev +--- + +# Task-create dependency selector refinement + +## Why + +The task-create dialog currently presents dependencies as a small action beside +a separate label and then renders selected tasks as chips below it. This makes +the control look different from the agent, executor, and workflow selectors, +and the dependency behavior is easy to miss when a user is planning an ordered +set of tasks. + +## What + +- In create mode, the dependency control appears in the row below the agent and + executor selectors, on the right of the workflow selector when that selector + is visible. +- The dependency control is one searchable selector. It keeps the existing + ability to choose multiple predecessor tasks by toggling task entries in the + same selector. +- With no predecessor selected, the trigger shows `No dependency` and the + dependency icon. With one predecessor selected, it shows that task's title. + With multiple predecessors selected, it shows a localized dependency count. +- The selector includes a `No dependency` entry that clears every selected + predecessor. A selected task can be removed by toggling its entry off. +- Every task entry in the selector displays a task icon. Archived tasks are not + offered as candidates, and the existing candidate sources from both Kanban + board slices remain available. +- The selector's search row has an info control on its right side. Hovering or + focusing the control explains that dependencies wait for all selected tasks + to finish successfully and that starting an agent starts this task + automatically after they resolve. The control has an accessible name and is + usable with touch and keyboard input. +- The existing `blockedBy` form state and `blocked_by` create payload remain + unchanged. Selecting several predecessors keeps the existing AND semantics. +- The dependency selector is disabled while the create flow is creating a + session, matching the current dependency control behavior. +- The selector is available only for an unstarted task in create mode. It does + not add an edge editor to task detail or edit mode. +- All new trigger labels, search copy, empty states, count labels, and help + copy use the task translation namespace. No new user-facing literal is + hardcoded in a component. + +## Mobile design contract + +- Desktop entry point: the dependency selector in the task-create dialog row + beside the workflow selector. Mobile entry point: the same full-width + selector in the dialog's vertically scrolling form. +- The nearest shipped mobile exemplar is the task-create repository picker and + its `Pill` command surface. Reuse its contained searchable picker geometry, + touch-safe actions, and no-horizontal-overflow expectations while keeping the + dependency selector's full-width selector trigger consistent with the agent + and executor controls. +- Mobile hierarchy is workflow first, dependency second, followed by the rest + of the form. The dependency trigger and each task row are the primary touch + targets for this surface. +- The picker remains a viewport-contained searchable popover with an internal + command-list scroll owner. The dialog form remains the outer scroll owner + when the picker is closed. No document-level horizontal scrolling is added. +- The selector shares task derivation, filtering, selection, and `blockedBy` + mutation logic across viewports. Only layout and touch presentation vary. + +## Scenarios + +- **GIVEN** a new task dialog with no selected predecessors, **WHEN** the user + views the workflow/dependency row, **THEN** the dependency trigger is on the + workflow row's right side and shows `No dependency` with its icon. +- **GIVEN** the workflow selector is hidden because a single workflow is + already enforced, **WHEN** the user opens the create dialog, **THEN** the + dependency selector remains available in the same row position instead of + disappearing with the workflow control. +- **GIVEN** the dependency trigger, **WHEN** the user opens it, **THEN** the + search field, the info control, the `No dependency` entry, and all non-archived + board tasks are available. +- **GIVEN** two non-archived candidate tasks, **WHEN** the user selects both + entries, **THEN** both entries are marked selected, the trigger shows a + localized count, and the form retains both task IDs in `blockedBy`. +- **GIVEN** selected predecessors, **WHEN** the user selects `No dependency`, + **THEN** the trigger returns to its default state and the form's `blockedBy` + value is empty. +- **GIVEN** the selector's info control, **WHEN** the user hovers, focuses, or + taps it, **THEN** the dependency explanation is readable without changing + the current task selection, and Escape or outside interaction dismisses it. +- **GIVEN** an archived board task, **WHEN** the user searches the dependency + selector, **THEN** the archived task is not offered. +- **GIVEN** a Pixel 5-sized viewport, **WHEN** the user opens the create dialog + and dependency selector, **THEN** the trigger and task rows remain reachable, + the picker stays within the viewport, and the document has no horizontal + overflow. +- **GIVEN** a task with selected predecessors, **WHEN** the user submits the + create dialog, **THEN** the request still contains the selected IDs in + `blocked_by`; no dependency API or backend contract changes. + +## Out of scope + +- Changing the dependency graph, auto-start semantics, or backend/API payload. +- Limiting a task to one predecessor. Multiple predecessors and AND semantics + remain supported. +- Adding dependency editing to task detail, Kanban card menus, or MCP. +- Adding a new mobile navigation surface or replacing the task-create dialog. +- Changing workflow, agent, executor, repository, or branch selector behavior.