feat: improve task dependency selector (#2627)

This commit is contained in:
Carlos Florêncio
2026-08-13 23:16:37 +01:00
committed by GitHub
parent f9c7fc5368
commit 009a88278c
21 changed files with 1858 additions and 122 deletions
@@ -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;
}) => (
<button
type="button"
data-testid={DEPENDENCY_TRIGGER_TEST_ID}
onClick={() => onChange([...value, "task-2"])}
>
{value.length === 0 ? "No dependency" : `${value.length} dependencies`}
</button>
),
}));
function renderAdvancedSettings(
overrides: Partial<React.ComponentProps<typeof TaskCreateAdvancedSettings>> = {},
) {
return render(
<TooltipProvider>
<TaskCreateAdvancedSettings
isCreateMode
isTaskStarted={false}
blockedBy={[]}
onBlockedByChange={() => {}}
{...overrides}
/>
</TooltipProvider>,
);
}
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<string[]>(["task-1"]);
return (
<TooltipProvider>
<TaskCreateAdvancedSettings
isCreateMode
isTaskStarted={false}
blockedBy={blockedBy}
onBlockedByChange={setBlockedBy}
/>
</TooltipProvider>
);
}
render(<Harness />);
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();
});
});
@@ -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 (
<Collapsible
open={open}
onOpenChange={setOpen}
className="min-w-0"
data-testid="task-create-advanced-settings"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="min-h-12 h-12 w-full justify-start gap-1 px-1 text-[11px] text-muted-foreground/70 hover:bg-transparent hover:text-muted-foreground cursor-pointer md:h-7 md:min-h-7"
data-testid="task-create-advanced-settings-trigger"
>
<span>{t("task:advancedSettings")}</span>
<IconChevronDown
className={cn("h-3 w-3 transition-transform", open && "rotate-180")}
aria-hidden="true"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className="min-w-0 pt-1"
data-testid="task-create-advanced-settings-content"
>
<div
className="grid min-w-0 grid-cols-1 gap-4 px-1 md:grid-cols-2"
data-testid="task-create-advanced-settings-grid"
>
<div
className="flex min-w-0 items-center gap-3"
data-testid="task-create-dependency-setting-row"
>
<div
className="flex min-h-11 shrink-0 items-center gap-1 text-[11px] text-muted-foreground/70 md:min-h-6"
data-testid="task-create-dependency-setting-label"
>
<span>{t("task:dependsOn")}</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 min-h-11 w-11 min-w-11 cursor-pointer p-0 text-muted-foreground/70 hover:bg-transparent hover:text-muted-foreground md:h-6 md:min-h-6 md:w-6 md:min-w-6"
aria-label={t("task:dependencyInfoLabel")}
data-testid="task-create-dependency-setting-info"
>
<IconInfoCircle className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="z-[60] max-w-xs">
{t("task:dependencyInfo")}
</TooltipContent>
</Tooltip>
</div>
<div className="min-w-0 flex-1" data-testid="task-create-dependency-selector-container">
<TaskCreateDependencies
value={blockedBy}
onChange={onBlockedByChange}
disabled={dependenciesDisabled}
/>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -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<string, { tasks?: KanbanState["tasks"] }> };
};
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]> = {},
): 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(
<TooltipProvider>
<TaskCreateDependencies value={value} onChange={onChange} />
</TooltipProvider>,
);
}
function ControlledDependencies({ initialValue = [] }: { initialValue?: string[] }) {
const [value, setValue] = useState(initialValue);
return <TaskCreateDependencies value={value} onChange={setValue} />;
}
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(
<TooltipProvider>
<ControlledDependencies />
</TooltipProvider>,
);
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(
<TooltipProvider>
<ControlledDependencies initialValue={[ALPHA_ID, BETA_ID]} />
</TooltipProvider>,
);
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);
});
});
@@ -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<string, { tasks?: KanbanState["tasks"] }> = {};
@@ -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<string, KanbanState["tasks"][number]>();
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 (
<CommandItem
value={`${task.title} ${task.id}`}
onSelect={onSelect}
className="min-h-12 cursor-pointer gap-2 md:min-h-11"
data-testid={`task-create-dependency-option-${task.id}`}
aria-selected={selected}
>
<IconListCheck
className="h-4 w-4 shrink-0 text-muted-foreground"
data-testid="task-create-dependency-task-icon"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<IconCheck
className={cn("h-4 w-4 shrink-0", selected ? "opacity-100" : "opacity-0")}
aria-hidden="true"
/>
</CommandItem>
);
}
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 (
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(320px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
data-testid="task-create-dependencies-popover"
portalContainer={portalContainer}
onWheel={(event) => event.stopPropagation()}
>
<Command>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<CommandInput placeholder={t("task:searchTasks")} />
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="mr-1 h-12 w-12 min-h-12 min-w-12 cursor-pointer md:h-9 md:w-9 md:min-h-9 md:min-w-9"
aria-label={t("task:dependencyInfoLabel")}
data-testid="task-create-dependency-info"
>
<IconInfoCircle className="h-4 w-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="z-[60] max-w-xs">
{t("task:dependencyInfo")}
</TooltipContent>
</Tooltip>
</div>
<CommandList>
<CommandEmpty>{t("task:noTasksFound")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="__no_dependency__"
forceMount
onSelect={() => {
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}
>
<IconLock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="flex-1">{t("task:noDependency")}</span>
<IconCheck
className={cn("h-4 w-4 shrink-0", value.length === 0 ? "opacity-100" : "opacity-0")}
aria-hidden="true"
/>
</CommandItem>
{candidates.map((task) => (
<DependencyOption
key={task.id}
task={task}
selected={value.includes(task.id)}
onSelect={() => {
onChange(
value.includes(task.id)
? value.filter((id) => id !== task.id)
: [...value, task.id],
);
}}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
);
}
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 = <DependencyTriggerLabel value={value} selectedTitle={selectedTitle} t={t} />;
return (
<div className="flex flex-col gap-1.5" data-testid="task-create-dependencies">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{t("task:dependsOn")}</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 cursor-pointer gap-1 px-2 text-xs"
disabled={disabled}
data-testid="task-create-dependencies-trigger"
>
<IconLock className="h-3.5 w-3.5" />
{t("task:addDependency")}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<Command>
<CommandInput placeholder={t("task:dependsOn")} />
<CommandList>
<CommandEmpty>{t("task:noDependencies")}</CommandEmpty>
<CommandGroup>
{candidates.map((task) => (
<CommandItem
key={task.id}
value={`${task.title} ${task.id}`}
onSelect={() => {
setOpen(false);
onChange([...value, task.id]);
}}
>
<span className="truncate">{task.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((task) => (
<Badge
key={task!.id}
variant="secondary"
className="h-5 max-w-[16rem] gap-1 text-xs"
data-testid="task-create-dependency-chip"
>
<span className="truncate">{task!.title}</span>
<button
type="button"
aria-label={t("task:removeDependency")}
className="cursor-pointer"
onClick={() => onChange(value.filter((id) => id !== task!.id))}
>
<IconX className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{selected.length > 0 && (
<p className="text-[11px] text-muted-foreground">{t("task:dependsOnWillAutoStart")}</p>
)}
<div className="min-w-0" data-testid="task-create-dependencies">
<Popover
open={open}
onOpenChange={(next) => {
if (!disabled) setOpen(next);
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
aria-label={t("task:dependsOn")}
aria-expanded={open}
className={cn(
"h-12 min-h-12 w-full min-w-0 justify-between md:h-7 md:min-h-7",
!disabled && "cursor-pointer",
)}
disabled={disabled}
data-testid="task-create-dependencies-trigger"
>
<span className="flex min-w-0 flex-1 items-center gap-2 text-left">
<IconLock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>
{triggerLabel}
</span>
</span>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
</Button>
</PopoverTrigger>
<DependencyPickerContent
candidates={candidates}
value={value}
onChange={onChange}
setOpen={setOpen}
portalContainer={portalContainer}
/>
</Popover>
</div>
);
}
@@ -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 }) => (
<button type="button">Workflow selector {selectedWorkflowId ?? "none"}</button>
),
}));
vi.mock("@/components/task-create-dialog-dependencies", () => ({
TaskCreateDependencies: ({ value }: { value: string[] }) => (
<button type="button" data-testid="task-create-dependencies-trigger">
{value.length > 0 ? `${value.length} dependencies` : "No dependency"}
</button>
),
}));
// Capture every render of TaskFormInputs so passthrough-related assertions can
// inspect the props that DialogPromptSection forwards to the textarea.
const taskFormInputsCalls: Array<Record<string, unknown>> = [];
@@ -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(
<WorkflowSection
isCreateMode
isTaskStarted={false}
workflows={workflows}
snapshots={{}}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={() => {}}
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();
});
});
@@ -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 = {
@@ -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}</>
),
+8 -8
View File
@@ -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}
/>
<CreateModeAgentSelectors {...props} />
{isCreateMode && !isTaskStarted && (
<TaskCreateDependencies
value={fs.blockedBy}
onChange={fs.setBlockedBy}
disabled={props.isCreatingSession}
/>
)}
{props.bottomSlot}
</>
);
@@ -190,6 +183,13 @@ function DialogFormBody(props: DialogFormBodyProps) {
agentProfiles={props.agentProfiles}
workflowLocked={props.workflowLocked}
/>
<TaskCreateAdvancedSettings
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
blockedBy={props.fs.blockedBy}
onBlockedByChange={props.fs.setBlockedBy}
dependenciesDisabled={props.isCreatingSession}
/>
</div>
);
}
@@ -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(() => {});
}
});
});
@@ -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");
});
});
+6
View File
@@ -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",
+6
View File
@@ -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àĩĺàƀĺē",
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+2
View File
@@ -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 |
@@ -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.
@@ -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.