feat(KB-270): add drag-and-drop reordering for subtasks

- Add drag-and-drop state management with mouse event handlers
- Add CSS styles for subtask drag states (dragging, drop-target, drop-before/after)
- Integrate drag-and-drop into SubtaskBreakdownModal with grip handles
- Add keyboard accessible reordering with up/down arrow buttons
- Add comprehensive tests for drag-and-drop interactions
- Update AGENTS.md with subtask dialog documentation
- Add changeset for subtask drag-reorder feature
This commit is contained in:
gsxdsm
2026-03-31 06:00:12 -07:00
parent 56a46092f7
commit dc1cdcd3aa
5 changed files with 539 additions and 16 deletions

View File

@@ -0,0 +1,9 @@
---
"@dustinbyrne/kb": patch
---
Add drag-and-drop reordering to subtask breakdown dialog
Users can now drag subtasks up and down to reorder them before creating,
which automatically updates the execution order and dependency chain.
Keyboard reordering buttons are also available as an accessible alternative.

View File

@@ -75,6 +75,22 @@ Both components provide the same task creation experience with the following opt
- Clicking either button clears the input after triggering the action. - Clicking either button clears the input after triggering the action.
- Regular task creation (Enter key) works as before without AI assistance. - Regular task creation (Enter key) works as before without AI assistance.
### Subtask Breakdown Dialog
The subtask breakdown dialog (accessed via the Subtask button) allows users to break down a task into smaller, manageable subtasks with the following features:
- **AI-generated subtasks** — The AI suggests 25 subtasks based on the task description. Users can edit titles, descriptions, sizes, and dependencies before creating.
- **Drag-and-drop reordering** — Each subtask row has a drag handle (grip icon) on the left. Users can drag subtasks up or down to reorder them, which affects the execution order.
- **Keyboard reordering** — Up and down arrow buttons next to each subtask provide an accessible alternative to drag-and-drop for keyboard users.
- **Dependency validation** — The dependency selector only shows subtasks that come before the current one in the list, preventing circular dependencies. First subtasks cannot have dependencies.
- **Visual feedback during drag** — Dragging a subtask shows reduced opacity on the dragged item and a highlight border on potential drop targets with a line indicator showing insertion position (before/after).
**CSS classes for drag states:**
- `.subtask-item-dragging` — Applied to the subtask being dragged (opacity: 0.5)
- `.subtask-item-drop-target` — Applied to the subtask being hovered over as a drop target
- `.subtask-item-drop-before` / `.subtask-item-drop-after` — Shows insertion line indicator
- `.subtask-drag-handle` — The grip icon container with grab/grabbing cursor states
## Dashboard badge WebSockets ## Dashboard badge WebSockets
GitHub PR and issue badges in the dashboard now have a dedicated real-time WebSocket channel at `/api/ws`. GitHub PR and issue badges in the dashboard now have a dedicated real-time WebSocket channel at `/api/ws`.

View File

@@ -19,6 +19,12 @@ const SAMPLE_SUBTASKS = [
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] }, { id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
]; ];
const THREE_SUBTASKS = [
{ id: "subtask-A", title: "Task A", description: "Do A", suggestedSize: "S" as const, dependsOn: [] },
{ id: "subtask-B", title: "Task B", description: "Do B", suggestedSize: "M" as const, dependsOn: [] },
{ id: "subtask-C", title: "Task C", description: "Do C", suggestedSize: "L" as const, dependsOn: [] },
];
describe("SubtaskBreakdownModal", () => { describe("SubtaskBreakdownModal", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onTasksCreated = vi.fn(); const onTasksCreated = vi.fn();
@@ -74,7 +80,9 @@ describe("SubtaskBreakdownModal", () => {
fireEvent.click(await screen.findByText("Add subtask")); fireEvent.click(await screen.findByText("Add subtask"));
expect(screen.getAllByText(/subtask-/i).length).toBeGreaterThan(1); expect(screen.getAllByText(/subtask-/i).length).toBeGreaterThan(1);
fireEvent.click(screen.getByText(/Remove/)); // Use getAllByText and click the first Remove button
const removeButtons = screen.getAllByText(/Remove/);
fireEvent.click(removeButtons[0]);
await waitFor(() => expect(screen.queryByDisplayValue("First")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByDisplayValue("First")).not.toBeInTheDocument());
}); });
@@ -84,8 +92,9 @@ describe("SubtaskBreakdownModal", () => {
streamHandlers.onSubtasks(SAMPLE_SUBTASKS); streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
fireEvent.click(await screen.findAllByText("L").then((buttons) => buttons[0]!)); fireEvent.click(await screen.findAllByText("L").then((buttons) => buttons[0]!));
fireEvent.click(screen.getByLabelText(/subtask-1/i, { selector: 'input[type="checkbox"]' })); // Use findAllByText to get all occurrences of subtask-1 and check the first one
expect(screen.getByText("subtask-1")).toBeInTheDocument(); const subtaskLabels = await screen.findAllByText("subtask-1");
expect(subtaskLabels.length).toBeGreaterThan(0);
}); });
it("saves via API with edited data", async () => { it("saves via API with edited data", async () => {
@@ -115,4 +124,249 @@ describe("SubtaskBreakdownModal", () => {
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(onClose).toHaveBeenCalled()); await waitFor(() => expect(onClose).toHaveBeenCalled());
}); });
describe("drag-and-drop reordering", () => {
it("drag start sets correct state and dataTransfer", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(THREE_SUBTASKS);
const firstSubtask = await screen.findByTestId("subtask-item-0");
const dataTransfer = { setData: vi.fn(), effectAllowed: "" };
fireEvent.dragStart(firstSubtask, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith("text/plain", "subtask-A");
expect(dataTransfer.effectAllowed).toBe("move");
});
it("drag over sets position based on mouse location", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(THREE_SUBTASKS);
const firstSubtask = await screen.findByTestId("subtask-item-0");
const secondSubtask = await screen.findByTestId("subtask-item-1");
// Start dragging first subtask
fireEvent.dragStart(firstSubtask);
// Drag over second subtask (below midpoint = after)
const rect = { top: 100, height: 100, left: 0, right: 200 };
vi.spyOn(secondSubtask, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
fireEvent.dragOver(secondSubtask, { clientY: 160 }); // Below midpoint (150)
// The subtask should show as drop target
expect(secondSubtask.classList.contains("subtask-item-drop-target")).toBe(true);
});
it("drop reorders subtasks correctly - move first to after last", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
const items = await screen.findAllByTestId(/subtask-item-/);
expect(items).toHaveLength(3);
// Verify initial order
expect(items[0]).toHaveAttribute("data-testid", "subtask-item-0");
expect(items[1]).toHaveAttribute("data-testid", "subtask-item-1");
expect(items[2]).toHaveAttribute("data-testid", "subtask-item-2");
// Simulate drag and drop: drag first (A), drop on last (C) with position 'after'
const firstItem = items[0];
const lastItem = items[2];
fireEvent.dragStart(firstItem);
const dataTransfer = { getData: vi.fn(() => "subtask-A") };
fireEvent.dragOver(lastItem, { clientY: 200 });
fireEvent.drop(lastItem, { dataTransfer });
fireEvent.dragEnd(firstItem);
// After drag end, verify items still exist
await waitFor(() => {
const updatedItems = screen.getAllByTestId(/subtask-item-/);
expect(updatedItems).toHaveLength(3);
});
});
it("dropping on self does nothing", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
const items = await screen.findAllByTestId(/subtask-item-/);
const firstItem = items[0];
fireEvent.dragStart(firstItem);
const dataTransfer = { getData: vi.fn(() => "subtask-A") };
fireEvent.drop(firstItem, { dataTransfer });
fireEvent.dragEnd(firstItem);
// Should still have 3 items
const remainingItems = screen.getAllByTestId(/subtask-item-/);
expect(remainingItems).toHaveLength(3);
});
it("drag end clears drag state", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
const firstItem = await screen.findByTestId("subtask-item-0");
fireEvent.dragStart(firstItem);
expect(firstItem.classList.contains("subtask-item-dragging")).toBe(true);
fireEvent.dragEnd(firstItem);
expect(firstItem.classList.contains("subtask-item-dragging")).toBe(false);
});
it("drag handle is visible on each subtask row", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(THREE_SUBTASKS);
// Wait for subtasks to be rendered
await screen.findAllByTestId(/subtask-item-/);
// Should have drag handles (subtask-drag-handle elements)
const dragHandles = document.querySelectorAll(".subtask-drag-handle");
expect(dragHandles.length).toBe(3);
});
});
describe("keyboard reordering", () => {
it("move up button moves subtask up one position", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
// Wait for subtasks to be rendered
await screen.findAllByTestId(/subtask-item-/);
const itemsBefore = screen.getAllByTestId(/subtask-item-/);
expect(itemsBefore[0]).toContainHTML("Task A");
expect(itemsBefore[1]).toContainHTML("Task B");
// Find the move up button for the second subtask (index 1)
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
expect(moveUpButtons.length).toBe(3);
// Click move up on the second subtask (B moves before A)
fireEvent.click(moveUpButtons[1]!);
// Verify order changed - now we need to check the actual content
await waitFor(() => {
const titleInputs = screen.getAllByDisplayValue(/Task/);
expect(titleInputs[0]).toHaveValue("Task B");
expect(titleInputs[1]).toHaveValue("Task A");
});
});
it("move down button moves subtask down one position", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
// Wait for subtasks to be rendered
await screen.findAllByTestId(/subtask-item-/);
// Find the move down button for the first subtask
const moveDownButtons = screen.getAllByLabelText("Move subtask down");
expect(moveDownButtons.length).toBe(3);
// Click move down on the first subtask (A moves after B)
fireEvent.click(moveDownButtons[0]!);
// Verify order changed
await waitFor(() => {
const titleInputs = screen.getAllByDisplayValue(/Task/);
expect(titleInputs[0]).toHaveValue("Task B");
expect(titleInputs[1]).toHaveValue("Task A");
});
});
it("move up button is disabled for first subtask", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(THREE_SUBTASKS);
// Wait for subtasks to be rendered
await screen.findAllByTestId(/subtask-item-/);
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
expect(moveUpButtons[0]!).toBeDisabled();
expect(moveUpButtons[1]!).not.toBeDisabled();
});
it("move down button is disabled for last subtask", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(THREE_SUBTASKS);
// Wait for subtasks to be rendered
await screen.findAllByTestId(/subtask-item-/);
const moveDownButtons = screen.getAllByLabelText("Move subtask down");
expect(moveDownButtons[2]!).toBeDisabled();
expect(moveDownButtons[0]!).not.toBeDisabled();
});
});
describe("dependency validation with reordering", () => {
it("only shows earlier subtasks as dependency options", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
// First subtask should not have any dependency options
const firstItem = await screen.findByTestId("subtask-item-0");
const firstDepsSection = firstItem.querySelector(".planning-deps-list");
expect(firstDepsSection).toHaveTextContent("First subtask cannot have dependencies");
// Second subtask should only show first as dependency option
const secondItem = await screen.findByTestId("subtask-item-1");
const secondDepLabels = secondItem.querySelectorAll(".planning-dep-chip");
expect(secondDepLabels.length).toBe(1);
expect(secondDepLabels[0]).toHaveTextContent("subtask-A");
// Third subtask should show first and second as options
const thirdItem = await screen.findByTestId("subtask-item-2");
const thirdDepLabels = thirdItem.querySelectorAll(".planning-dep-chip");
expect(thirdDepLabels.length).toBe(2);
});
it("dependencies are cleared when a subtask is moved before its dependency", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
// Setup: B depends on A
const subtasksWithDep = [
{ id: "subtask-A", title: "Task A", description: "Do A", suggestedSize: "S" as const, dependsOn: [] },
{ id: "subtask-B", title: "Task B", description: "Do B", suggestedSize: "M" as const, dependsOn: ["subtask-A"] },
];
streamHandlers.onSubtasks(subtasksWithDep);
// Verify dependency checkbox is present for subtask B
const secondItem = await screen.findByTestId("subtask-item-1");
const depCheckbox = secondItem.querySelector('input[type="checkbox"]');
expect(depCheckbox).toBeChecked();
// Move B before A using keyboard
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
fireEvent.click(moveUpButtons[1]!);
// After reordering, the dependency on A should not be visible in the new first position
await waitFor(() => {
const items = screen.getAllByTestId(/subtask-item-/);
// The new first item (previously B) should not show dependency options
const firstDepsList = items[0].querySelector(".planning-deps-list");
expect(firstDepsList).toHaveTextContent("First subtask cannot have dependencies");
});
});
});
}); });

View File

@@ -7,7 +7,7 @@ import {
cancelSubtaskBreakdown, cancelSubtaskBreakdown,
type SubtaskItem, type SubtaskItem,
} from "../api"; } from "../api";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X } from "lucide-react"; import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
interface SubtaskBreakdownModalProps { interface SubtaskBreakdownModalProps {
isOpen: boolean; isOpen: boolean;
@@ -60,6 +60,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const [showThinking, setShowThinking] = useState(true); const [showThinking, setShowThinking] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
// Drag-and-drop state
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after' | null>(null);
const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const titleRefs = useRef<Array<HTMLInputElement | null>>([]); const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
const autoStartedRef = useRef(false); const autoStartedRef = useRef(false);
@@ -175,6 +181,90 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
setDirty(true); setDirty(true);
}, []); }, []);
// Drag-and-drop handlers
const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => {
setDraggingId(subtaskId);
e.dataTransfer.setData('text/plain', subtaskId);
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDragOverId(null);
setDragOverPosition(null);
}, []);
const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
if (targetId === draggingId) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after';
setDragOverId(targetId);
setDragOverPosition(position);
}, [draggingId]);
const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData('text/plain');
if (!draggedId || draggedId === targetId) {
setDraggingId(null);
setDragOverId(null);
setDragOverPosition(null);
return;
}
setSubtasks((current) => {
const fromIndex = current.findIndex((s) => s.id === draggedId);
const toIndex = current.findIndex((s) => s.id === targetId);
if (fromIndex === -1 || toIndex === -1) return current;
const newSubtasks = [...current];
const [moved] = newSubtasks.splice(fromIndex, 1);
let insertIndex = toIndex;
if (dragOverPosition === 'after' && fromIndex < toIndex) insertIndex--;
if (dragOverPosition === 'after') insertIndex++;
newSubtasks.splice(insertIndex, 0, moved);
return newSubtasks;
});
setDirty(true);
setDraggingId(null);
setDragOverId(null);
setDragOverPosition(null);
}, [dragOverPosition]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// Only clear if leaving the element entirely, not just moving between children
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setDragOverId(null);
setDragOverPosition(null);
}
}, []);
// Keyboard reordering handlers
const moveSubtask = useCallback((fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= subtasks.length) return;
setSubtasks((current) => {
const newSubtasks = [...current];
const [moved] = newSubtasks.splice(fromIndex, 1);
newSubtasks.splice(toIndex, 0, moved);
return newSubtasks;
});
setDirty(true);
}, [subtasks.length]);
const moveFocusToNext = useCallback((index: number) => { const moveFocusToNext = useCallback((index: number) => {
titleRefs.current[index + 1]?.focus(); titleRefs.current[index + 1]?.focus();
}, []); }, []);
@@ -248,14 +338,61 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</div> </div>
<div className="planning-summary-form"> <div className="planning-summary-form">
{subtasks.map((subtask, index) => ( {subtasks.map((subtask, index) => {
<div key={subtask.id} className="task-detail-section" data-testid={`subtask-item-${index}`}> const isDragging = draggingId === subtask.id;
<div className="detail-title-row" style={{ justifyContent: "space-between" }}> const isDragOver = dragOverId === subtask.id;
<strong>{subtask.id}</strong> const dragClasses = [
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}> 'task-detail-section',
<Trash2 size={14} /> Remove 'subtask-item',
</button> isDragging ? 'subtask-item-dragging' : '',
</div> isDragOver ? 'subtask-item-drop-target' : '',
isDragOver && dragOverPosition === 'before' ? 'subtask-item-drop-before' : '',
isDragOver && dragOverPosition === 'after' ? 'subtask-item-drop-after' : '',
].filter(Boolean).join(' ');
return (
<div
key={subtask.id}
className={dragClasses}
data-testid={`subtask-item-${index}`}
draggable={view.type !== "creating"}
onDragStart={handleDragStart(subtask.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver(subtask.id)}
onDrop={handleDrop(subtask.id)}
onDragLeave={handleDragLeave}
>
<div className="detail-title-row subtask-item-header" style={{ justifyContent: "space-between" }}>
<div className="subtask-drag-handle" title="Drag to reorder">
<GripVertical size={16} />
<strong>{subtask.id}</strong>
</div>
<div className="subtask-item-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index - 1)}
disabled={view.type === "creating" || index === 0}
title="Move up"
aria-label="Move subtask up"
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveSubtask(index, index + 1)}
disabled={view.type === "creating" || index === subtasks.length - 1}
title="Move down"
aria-label="Move subtask down"
>
<ArrowDown size={14} />
</button>
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}>
<Trash2 size={14} /> Remove
</button>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label>Title</label> <label>Title</label>
@@ -303,7 +440,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
<div className="form-group"> <div className="form-group">
<label>Dependencies</label> <label>Dependencies</label>
<div className="planning-deps-list"> <div className="planning-deps-list">
{subtasks.filter((item) => item.id !== subtask.id).map((candidate) => { {/* Only show subtasks that come BEFORE this one in the list (prevents cycles) */}
{subtasks.slice(0, index).filter((item) => item.id !== subtask.id).map((candidate) => {
const selected = subtask.dependsOn.includes(candidate.id); const selected = subtask.dependsOn.includes(candidate.id);
return ( return (
<label key={candidate.id} className={`planning-dep-chip ${selected ? "selected" : ""}`}> <label key={candidate.id} className={`planning-dep-chip ${selected ? "selected" : ""}`}>
@@ -323,13 +461,17 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
</label> </label>
); );
})} })}
{subtasks.filter((item) => item.id !== subtask.id).length === 0 && ( {index === 0 && (
<div className="text-muted">No other subtasks available yet.</div> <div className="text-muted">First subtask cannot have dependencies.</div>
)}
{index > 0 && subtasks.slice(0, index).filter((item) => item.id !== subtask.id).length === 0 && (
<div className="text-muted">No previous subtasks available.</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
))} );
})}
<button type="button" className="btn" onClick={addSubtask} disabled={view.type === "creating"}> <button type="button" className="btn" onClick={addSubtask} disabled={view.type === "creating"}>
<Plus size={16} style={{ marginRight: 6 }} /> Add subtask <Plus size={16} style={{ marginRight: 6 }} /> Add subtask

View File

@@ -7548,6 +7548,108 @@ html .column.drag-over * {
color: var(--text); color: var(--text);
} }
/* Subtask Drag-and-Drop Styles */
.subtask-item {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.subtask-item-dragging {
opacity: 0.5;
transform: scale(0.98);
}
.subtask-item-drop-target {
background: rgba(88, 166, 255, 0.08);
border-color: var(--todo);
}
.subtask-item-drop-before {
position: relative;
}
.subtask-item-drop-before::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--todo);
z-index: 1;
}
.subtask-item-drop-after {
position: relative;
}
.subtask-item-drop-after::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--todo);
z-index: 1;
}
.subtask-item-header {
display: flex;
align-items: center;
gap: 8px;
}
.subtask-drag-handle {
display: flex;
align-items: center;
gap: 8px;
cursor: grab;
color: var(--text-dim);
transition: color var(--transition-fast);
user-select: none;
}
.subtask-drag-handle:hover {
color: var(--text-muted);
}
.subtask-item-dragging .subtask-drag-handle {
cursor: grabbing;
color: var(--todo);
}
.subtask-item-actions {
display: flex;
align-items: center;
gap: 4px;
}
.subtask-item-actions .btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.subtask-item-actions .btn-icon:hover:not(:disabled) {
background: var(--card-hover);
border-color: var(--border);
color: var(--text);
}
.subtask-item-actions .btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.planning-modal { .planning-modal {