feat: improve task dependency selector (#2627)
This commit is contained in:
@@ -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}</>
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user