diff --git a/dashboard/src/v2/TasksPage.tsx b/dashboard/src/v2/TasksPage.tsx index 3cef78d1ab..216e449241 100644 --- a/dashboard/src/v2/TasksPage.tsx +++ b/dashboard/src/v2/TasksPage.tsx @@ -1,32 +1,51 @@ import type { FunctionComponent } from "preact"; -import { memo } from "preact/compat"; import { useLayoutEffect, useMemo, useRef } from "preact/hooks"; import gsap from "gsap"; import { Link } from "@tanstack/react-router"; import { ListChecks, FolderGit2, - Flame, - Target, Plus, X, - ArrowUpRight, ArrowRight, + AlertTriangle, } from "lucide-preact"; import { TaskComposer } from "./components/ui/TaskComposer.js"; import { AddProjectModal } from "./components/ui/AddProjectModal.js"; -import type { Task } from "./types.js"; import { PageContainer } from "./components/layout/PageContainer.js"; import { PageHeader } from "./components/layout/PageHeader.js"; import { Button } from "./components/ui/Button.js"; import { TaskBoardFilters } from "./components/tasks/TaskBoardFilters.js"; import { TaskBoardColumns } from "./components/tasks/TaskBoardColumns.js"; +import { TaskBoardOverview } from "./components/tasks/TaskBoardOverview.js"; import { useInteractionTokens } from "./lib/motion/tokens.js"; import { useTaskBoardController } from "./hooks/use-task-board-controller.js"; type TaskScopePlaceholderMode = "project" | "sprint"; +export const TaskBoardFeedback: FunctionComponent<{ + error: string | null; + filterTransitionPending: boolean; +}> = ({ error, filterTransitionPending }) => ( + <> + {error && ( +
+
+ )} + {filterTransitionPending && ( +
+ Updating task board filters. Current cards remain visible until results settle. +
+ )} + +); + const TaskScopePlaceholder: FunctionComponent<{ mode: TaskScopePlaceholderMode; hasProjects: boolean; @@ -40,26 +59,19 @@ const TaskScopePlaceholder: FunctionComponent<{ : "Tasks are organized inside sprint scope. Create or select a sprint before adding implementation work to the board."; return ( -
-
-
-
-
-
-
- -
+
+
-
- +
+
{eyebrow}
-

+

{title} -

-

+ +

{body}

@@ -70,14 +82,14 @@ const TaskScopePlaceholder: FunctionComponent<{ onClick={onAddProject} variant="signal" icon={Plus} - className="!inline-flex !min-h-[44px] !items-center !gap-2.5 !rounded-full !px-5 !py-2.5 !text-[10px] !font-bold !uppercase !tracking-[0.14em] !shadow-[0_10px_30px_rgba(0,224,160,0.22)] hover:!-translate-y-px focus-visible:!ring-2 focus-visible:!ring-signal-500/40" + className="!inline-flex !min-h-[44px] !items-center !gap-2.5 !rounded-xl !px-5 !py-2.5 !text-[10px] !font-bold !uppercase !tracking-[0.14em] focus-visible:!ring-2 focus-visible:!ring-signal-500/40" > {hasProjects ? "Add Project" : "Add First Project"} ) : ( Plan Sprint @@ -85,7 +97,7 @@ const TaskScopePlaceholder: FunctionComponent<{ )} {isProjectMode ? "Manage Projects" : "Open Sprints"} @@ -94,9 +106,8 @@ const TaskScopePlaceholder: FunctionComponent<{
-
-
-
+
+
{[ { label: "Project", value: isProjectMode ? "required" : "ready", tone: isProjectMode ? "text-ember-500" : "text-status-green" }, { label: "Sprint", value: isProjectMode ? "waiting" : "required", tone: isProjectMode ? "text-signal-500" : "text-ember-500" }, @@ -104,16 +115,14 @@ const TaskScopePlaceholder: FunctionComponent<{ ].map((item, index) => (
{item.label}
{item.value}
-
- -
+
))} @@ -124,71 +133,7 @@ const TaskScopePlaceholder: FunctionComponent<{ ); }; -const SprintProgressCard: FunctionComponent<{ - sprint: { id: string; name: string; date: string }; - tasks: Task[]; -}> = memo(({ sprint, tasks }) => { - const completed = tasks.filter((task) => task.status === "completed").length; - const inProgress = tasks.filter((task) => task.status === "in_progress").length; - const pending = tasks.filter((task) => task.status === "pending").length; - const total = tasks.length; - const pct = total > 0 ? Math.round((completed / total) * 100) : 0; - - return ( -
-
- {pct}% -
- -
-
- -
-
-

{sprint.name}

-

{sprint.date}

-
-
- -
- {completed > 0 &&
} - {inProgress > 0 &&
} - {pending > 0 &&
} -
- -
- {[ - { label: "Completed", value: completed, color: "text-status-green" }, - { label: "Running", value: inProgress, color: "text-signal-500" }, - { label: "Queued", value: pending, color: "text-slate-400" }, - ].map(({ label, value, color }) => ( -
- {value} - {label} -
- ))} -
- - - - View Sprint - -
- ); -}); - export const TasksPage: FunctionComponent = () => { - const headerRef = useRef(null); const boardRef = useRef(null); const interactionTokens = useInteractionTokens(); const controller = useTaskBoardController(); @@ -246,14 +191,6 @@ export const TasksPage: FunctionComponent = () => { transitionTimingFunction: interactionTokens.listReorder.ease, }), [interactionTokens.listReorder.duration, interactionTokens.listReorder.ease]); - useLayoutEffect(() => { - if (!headerRef.current) return; - const ctx = gsap.context(() => { - gsap.fromTo(Array.from(headerRef.current!.children), { opacity: 0, y: 40 }, { opacity: 1, y: 0, stagger: 0.1, duration: 0.9, ease: "power4.out", delay: 0.05 }); - }); - return () => ctx.revert(); - }, []); - useLayoutEffect(() => { if (!boardRef.current || loading || showSkeletons) return; const taskCards = Array.from(boardRef.current.querySelectorAll(".task-card-entry")); @@ -281,7 +218,12 @@ export const TasksPage: FunctionComponent = () => { const el = boardRef.current.querySelector(`[data-task-id="${resolvedTaskId}"] .kanban-card`) as HTMLDivElement; if (!el) return; - el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + el.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "nearest" }); + + if (reducedMotion) { + clearResolvedTaskId(); + return; + } const flashEl = document.createElement("div"); flashEl.style.position = "absolute"; @@ -321,19 +263,18 @@ export const TasksPage: FunctionComponent = () => { clearResolvedTaskId(); return () => ctx.revert(); - }, [clearResolvedTaskId, resolvedTaskId, tasks]); + }, [clearResolvedTaskId, reducedMotion, resolvedTaskId, tasks]); return ( {selectedProject @@ -349,38 +290,15 @@ export const TasksPage: FunctionComponent = () => { } actions={ -
-
- {stats.inProgress > 0 && ( -
- - - - {stats.inProgress} Running -
- )} - {stats.critical > 0 && ( -
- - {stats.critical} Critical -
- )} -
- - {stats.total} Total -
-
- -
} /> @@ -403,7 +321,7 @@ export const TasksPage: FunctionComponent = () => { {isTaskScopeReady && (

Task workspace

@@ -426,24 +344,13 @@ export const TasksPage: FunctionComponent = () => { onListWindowChange={setListWindow} /> - {selectedSprintModel && ( - - )} + - {error && ( -
- {error} -
- )} +
{boardCountAnnouncement}
- {filterTransitionPending && ( -
- Updating task board filters. Current cards remain visible until results settle. -
- )} {announcement}
{summary}
- {indicators.map((dep) => { - const statusText = dep.status.replace(/_/g, ' '); - const statusCopy = getDependencyStatusCopy(dep); - const dependencyState = getDependencyState(dep); - const blockingCopy = isDependencyBlocking(dep) ? "Blocking dependency" : "Resolved dependency"; - const blockingBadge = isDependencyBlocking(dep) ? "Blocking" : "Clear"; - const stateDescription = dep.stateDescription ?? getDependencyPresentation(dep.status, dep.isKnown !== false && !dep.title.startsWith("Unknown Task")).stateDescription; - const containerClass = getDependencyToneClass(dep); +
+ {indicators.map((dep) => { + const statusText = dep.status.replace(/_/g, " "); + const statusCopy = getDependencyStatusCopy(dep); + const dependencyState = getDependencyState(dep); + const blockingCopy = isDependencyBlocking(dep) ? "Blocking dependency" : "Resolved dependency"; + const stateDescription = dep.stateDescription ?? getDependencyPresentation(dep.status, isDependencyKnown(dep)).stateDescription; + const containerClass = getDependencyToneClass(dep); - return ( -
- Depends on task {dep.id}, {statusCopy.toLowerCase()}. {blockingCopy}. {stateDescription}. Status: {statusText}. Title: {dep.title} -
- ); - })} -
+ return ( +
+ Depends on task {dep.id}, {statusCopy.toLowerCase()}. {blockingCopy}. {stateDescription}. Status: {statusText}. Title: {dep.title} +
+ ); + })} +
+
); }); diff --git a/dashboard/src/v2/components/tasks/KanbanTaskCard.tsx b/dashboard/src/v2/components/tasks/KanbanTaskCard.tsx index dee30d57be..6a53b91d84 100644 --- a/dashboard/src/v2/components/tasks/KanbanTaskCard.tsx +++ b/dashboard/src/v2/components/tasks/KanbanTaskCard.tsx @@ -1,7 +1,7 @@ -import { Fragment, type FunctionComponent } from "preact"; +import type { FunctionComponent } from "preact"; import { memo } from "preact/compat"; import { useRef } from "preact/hooks"; -import { Clock, Eye, FolderGit2, GitPullRequest, Maximize2, RotateCcw, Settings, Trash2 } from "lucide-preact"; +import { Clock, FolderGit2, GitPullRequest } from "lucide-preact"; import { WaveFluid } from "../ui/WaveFluid.js"; import { BorderTrace } from "../ui/BorderTrace.js"; import type { Task } from "../../types.js"; @@ -9,9 +9,7 @@ import { PRIORITY_CFG, STATUS_CFG } from "../../lib/tasks-constants.js"; import { useTaskCardMotion, useTaskCardDragMotion } from "../../lib/motion/task-card-motion.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; -import { useConfirmDialog } from "../../hooks/use-confirm-dialog.js"; -import { ConfirmDialog } from "../ui/ConfirmDialog.js"; -import { type TaskCardActionDescriptor, type TaskCardViewModel, formatTimeAgo } from "../../lib/tasks/task-card-view-model.js"; +import { type TaskCardViewModel, formatTimeAgo } from "../../lib/tasks/task-card-view-model.js"; import { useState, useEffect } from "preact/hooks"; import { DependencyStatusIndicators } from "./DependencyStatusIndicators.js"; import { LiveDurationBadge } from "../ui/LiveDurationBadge.js"; @@ -22,6 +20,7 @@ import { getSafeUrl } from "../../lib/safe-url.js"; import { SelfReflectionRatingBadge } from "./SelfReflectionRatingBadge.js"; import { SprintReviewBadge } from "../sprints/SprintReviewBadge.js"; import { CiStatusBadge } from "../ui/CiStatusBadge.js"; +import { TaskCardActionMenu } from "./TaskCardActionMenu.js"; export const KanbanTaskCard: FunctionComponent<{ viewModel: TaskCardViewModel; @@ -44,7 +43,6 @@ export const KanbanTaskCard: FunctionComponent<{ const qaNoReviewLabel = viewModel.qaReviewLabel ?? "QA no review"; const dragStateLabel = viewModel.dragStateLabel ?? "Pointer drag only; keyboard reordering is not supported"; const shouldShowExecutorLabel = viewModel.executorLabel !== "Auto"; - const cardActions = viewModel.actions ?? []; const hasPullRequestMetadata = viewModel.hasPullRequestMetadata ?? true; const dependencySummary = dependencyIndicators.length === 0 ? "No dependency blockers." @@ -80,23 +78,6 @@ export const KanbanTaskCard: FunctionComponent<{ task.isOptimistic ? `Saving task ${task.id}; actions that would change this task are temporarily unavailable.` : null, ].filter(Boolean).join(" "); const StatusIcon = STATUS_CFG[task.status].icon; - const { isOpen: isConfirmOpen, options: confirmOptions, requestConfirm, handleConfirm, handleCancel, triggerRef } = useConfirmDialog(); - const actionIconByKind: Record = { - rerun: RotateCcw, - preview: Eye, - pull_request: GitPullRequest, - live_runtime: Maximize2, - }; - const unavailableActionSummary = task.isOptimistic - ? `Saving task ${task.id}; actions are paused.` - : cardActions - .filter((action) => action.disabledReason) - .map((action) => action.label) - .join(", "); - const unavailableActionSummaryText = unavailableActionSummary && !task.isOptimistic - ? `Unavailable: ${unavailableActionSummary}.` - : unavailableActionSummary; - const [flashTriggerCount, setFlashTriggerCount] = useState(0); const prevRunningTimeRef = useRef(liveRunningTime); @@ -127,7 +108,7 @@ export const KanbanTaskCard: FunctionComponent<{ data-dragging={effectiveIsDragging ? "true" : undefined} data-drag-disabled={isDragDisabled ? "true" : undefined} aria-busy={task.isOptimistic ? "true" : "false"} - className={`kanban-card group relative flex flex-col bg-white/80 dark:bg-void-800/75 backdrop-blur-sm rounded-[1.75rem] p-7 shadow-[0_2px_20px_rgba(0,0,0,0.04)] dark:shadow-[0_4px_24px_rgba(0,0,0,0.2)] overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 focus-visible:ring-offset-2 ${task.isOptimistic ? "border-dashed border-2 border-slate-300 dark:border-slate-600 opacity-70" : "border border-black/[0.06] dark:border-white/[0.06]"} ${isReducedMotion ? 'kanban-card-reduced-motion' : ''} ${effectiveIsDragging ? 'kanban-card--dragging ring-2 ring-signal-500' : ''}`} + className={`kanban-card group relative flex flex-col bg-white/80 dark:bg-void-800/75 backdrop-blur-sm rounded-[1.75rem] p-5 shadow-[0_2px_20px_rgba(0,0,0,0.04)] dark:shadow-[0_4px_24px_rgba(0,0,0,0.2)] overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 focus-visible:ring-offset-2 ${task.isOptimistic ? "border-dashed border-2 border-slate-300 dark:border-slate-600 opacity-70" : "border border-black/[0.06] dark:border-white/[0.06]"} ${isReducedMotion ? 'kanban-card-reduced-motion' : ''} ${effectiveIsDragging ? 'kanban-card--dragging ring-2 ring-signal-500' : ''}`} style={{ transformStyle: "preserve-3d", willChange: "transform", @@ -164,7 +145,7 @@ export const KanbanTaskCard: FunctionComponent<{ -
+
{task.id.toUpperCase()} @@ -190,13 +171,13 @@ export const KanbanTaskCard: FunctionComponent<{
-

{task.title}

-
+
{shouldShowExecutorLabel && ( {viewModel.executorLabel} @@ -267,7 +248,7 @@ export const KanbanTaskCard: FunctionComponent<{
-
+
e.stopPropagation()} aria-label={`Open pull request for task ${task.id}`} + title={`Open pull request for task ${task.id}`} >
- - {liveStartedAt ? `· ${formatTimeAgo(liveStartedAt)}` : humanizedCreatedAt} - -
- -
- {unavailableActionSummaryText && ( - - )} - {cardActions.map((action) => { - const ActionIcon = actionIconByKind[action.kind]; - const disabledReason = task.isOptimistic - ? `Saving task ${task.id}; ${action.label} is temporarily unavailable.` - : action.disabledReason; - const actionClassName = `kanban-card__action inline-flex min-h-8 items-center gap-1.5 rounded-full px-2 py-1.5 text-[9px] font-bold uppercase tracking-[0.12em] transition-colors active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 ${ - disabledReason - ? "text-slate-400 dark:text-slate-500 cursor-not-allowed" - : "text-slate-500 hover:text-signal-600 dark:text-slate-400 dark:hover:text-signal-400" - }`; - - if (action.href && !disabledReason) { - return ( - event.stopPropagation()} - > - - {action.label} - - ); - } - - const reasonId = `task-card-action-reason-${task.recordId}-${action.kind}`; - - return ( - - - - {disabledReason ?? "Unavailable"} - - - ); - })} - - {task.isOptimistic && ( - - Saving task {task.id}; edit is temporarily unavailable. - - )} - - {task.isOptimistic && ( - - Saving task {task.id}; delete is temporarily unavailable. +
+ + {liveStartedAt ? `· ${formatTimeAgo(liveStartedAt)}` : humanizedCreatedAt} - )} + +
- -
); }, (prev, next) => ( diff --git a/dashboard/src/v2/components/tasks/TaskBoardColumns.tsx b/dashboard/src/v2/components/tasks/TaskBoardColumns.tsx index 619a29f5d5..bf09705692 100644 --- a/dashboard/src/v2/components/tasks/TaskBoardColumns.tsx +++ b/dashboard/src/v2/components/tasks/TaskBoardColumns.tsx @@ -15,16 +15,24 @@ const ColumnHeader: FunctionComponent<{ status: TaskStatus; count: number }> = m const headingId = `task-lane-heading-${status}`; return ( -
-
- -

{cfg.label}

+
+
+
+
+

+ {cfg.label} + lane, {count} {count === 1 ? "task" : "tasks"} +

- - - {count} {count === 1 ? "task" : "tasks"} + -
+ ); }); @@ -159,7 +167,8 @@ const TaskBoardColumnsComponent: FunctionComponent = ({ style={listTransitionStyle} data-motion-list-reorder="listReorder" data-motion-list-reveal="listReveal" - className={`grid gap-6 transition-opacity ${filterTransitionPending ? "opacity-80" : "opacity-100"} ${ + data-board-column-count={columns.length} + className={`grid min-w-0 grid-cols-1 gap-4 transition-opacity motion-reduce:transition-none sm:gap-5 ${filterTransitionPending ? "opacity-80" : "opacity-100"} ${ columns.length === 1 ? "grid-cols-1" : columns.length === 2 ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 lg:grid-cols-2 xl:grid-cols-3" @@ -168,21 +177,24 @@ const TaskBoardColumnsComponent: FunctionComponent = ({ {columns.map(({ status, count, tasks: columnTasks }) => (

{STATUS_CFG[status].label} lane contains {count} {count === 1 ? "task" : "tasks"} after current filters.

onDragOver(status, columnTasks.length, event as DragEvent)} onDrop={(event) => onDrop(status, event as DragEvent)} aria-describedby={`task-lane-summary-${status} task-lane-drop-${status}`} + data-drop-active={dropTargetContext?.status === status ? "true" : "false"} >

{getTaskDropFeedback({ @@ -201,7 +213,7 @@ const TaskBoardColumnsComponent: FunctionComponent = ({ show={showSkeletons} className="col-start-1 row-start-1" skeleton={( -

+
); }; diff --git a/dashboard/src/v2/components/tasks/TaskBoardOverview.tsx b/dashboard/src/v2/components/tasks/TaskBoardOverview.tsx new file mode 100644 index 0000000000..b7f6519229 --- /dev/null +++ b/dashboard/src/v2/components/tasks/TaskBoardOverview.tsx @@ -0,0 +1,99 @@ +import type { FunctionComponent } from "preact"; +import { CheckCircle2, Flame, ListChecks, PlayCircle, Target } from "lucide-preact"; +import type { Sprint, Task } from "../../types.js"; +import type { TaskBoardState } from "../../lib/task-board-state.js"; +import { getTaskLane } from "../../lib/task-board-state.js"; + +export interface TaskBoardOverviewProps { + sprint: Pick | null; + tasks: Task[]; + stats: TaskBoardState["stats"]; +} + +const overviewMetrics = [ + { key: "total", label: "Filtered total", icon: ListChecks, tone: "text-slate-700 dark:text-slate-200" }, + { key: "inProgress", label: "Running", icon: PlayCircle, tone: "text-signal-600 dark:text-signal-400" }, + { key: "completed", label: "Completed", icon: CheckCircle2, tone: "text-status-green" }, + { key: "critical", label: "Critical", icon: Flame, tone: "text-status-red" }, +] as const; + +export const TaskBoardOverview: FunctionComponent = ({ sprint, tasks, stats }) => { + const completed = tasks.filter((task) => getTaskLane(task.status) === "completed").length; + const inProgress = tasks.filter((task) => getTaskLane(task.status) === "in_progress").length; + const queued = tasks.filter((task) => getTaskLane(task.status) === "pending").length; + const total = tasks.length; + const completion = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+

Task board overview

+
+ {sprint && ( +
+
+
+
+
+

+ Active sprint scope +

+

+ {sprint.name} +

+

{sprint.date}

+
+
+ {completion}% + Complete +
+
+ +
+
+
+ +

+ {completed} completed + + {inProgress} running + + {queued} queued +

+
+ )} + +
+ {overviewMetrics.map(({ key, label, icon: Icon, tone }, index) => ( +
+
+
+ + {stats[key]} + +
+ ))} +
+
+
+ ); +}; diff --git a/dashboard/src/v2/components/tasks/TaskBoardSprintSelector.tsx b/dashboard/src/v2/components/tasks/TaskBoardSprintSelector.tsx index 9336a0fa4a..b3c340fb1c 100644 --- a/dashboard/src/v2/components/tasks/TaskBoardSprintSelector.tsx +++ b/dashboard/src/v2/components/tasks/TaskBoardSprintSelector.tsx @@ -171,7 +171,7 @@ export const TaskBoardSprintSelector: FunctionComponent handleTriggerKeyDown(event as KeyboardEvent)} style={{ transitionDuration: interactionTokens.controlFeedback.duration, transitionTimingFunction: interactionTokens.controlFeedback.ease }} - className={`group flex items-center gap-3 px-5 py-3 rounded-2xl border transition-all min-w-0 max-w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ember-500/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-void-900 ${ + className={`group flex min-h-[44px] w-full min-w-0 max-w-full items-center gap-3 rounded-xl border px-4 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-void-900 ${ selected ? "bg-ember-500/[0.06] dark:bg-ember-500/[0.08] border-ember-500/20 dark:border-ember-500/25 shadow-[0_0_20px_rgba(255,184,0,0.06)]" : "bg-black/[0.03] dark:bg-white/[0.03] border-black/[0.06] dark:border-white/[0.06]" - } hover:border-ember-500/40 dark:hover:border-ember-500/40`} + } hover:border-signal-500/35 dark:hover:border-signal-500/35`} > @@ -228,7 +228,7 @@ export const TaskBoardSprintSelector: FunctionComponent {scopeState.isLoading ? "Loading" : scopeState.isScoped ? "Selected" : scopeState.isEmpty ? "Empty" : "All"} - + {open && ( @@ -239,7 +239,7 @@ export const TaskBoardSprintSelector: FunctionComponent diff --git a/dashboard/src/v2/components/tasks/TaskCardActionMenu.tsx b/dashboard/src/v2/components/tasks/TaskCardActionMenu.tsx new file mode 100644 index 0000000000..dfefd8fbd2 --- /dev/null +++ b/dashboard/src/v2/components/tasks/TaskCardActionMenu.tsx @@ -0,0 +1,222 @@ +import type { FunctionComponent } from "preact"; +import { memo } from "preact/compat"; +import { useState } from "preact/hooks"; +import { + Eye, + GitPullRequest, + Maximize2, + MoreHorizontal, + RotateCcw, + Settings, + Trash2, +} from "lucide-preact"; +import { useConfirmDialog } from "../../hooks/use-confirm-dialog.js"; +import { getSafeUrl } from "../../lib/safe-url.js"; +import type { + TaskCardActionDescriptor, + TaskCardViewModel, +} from "../../lib/tasks/task-card-view-model.js"; +import { ConfirmDialog } from "../ui/ConfirmDialog.js"; +import { DropdownMenu, DropdownMenuItem } from "../ui/DropdownMenu.js"; + +const actionIconByKind: Record = { + rerun: RotateCcw, + preview: Eye, + pull_request: GitPullRequest, + live_runtime: Maximize2, +}; + +const menuItemClassName = "kanban-card__menu-item flex w-full min-w-0 items-start gap-2.5 rounded-xl px-2.5 py-2 text-left text-xs font-semibold text-slate-600 outline-none dark:text-slate-300"; + +export const TaskCardActionMenu: FunctionComponent<{ + viewModel: TaskCardViewModel; + onEdit: (task: TaskCardViewModel["task"]) => void; + onDelete: (task: TaskCardViewModel["task"]) => void; +}> = memo(({ viewModel, onEdit, onDelete }) => { + const { task } = viewModel; + const [isMenuOpen, setIsMenuOpen] = useState(false); + const confirm = useConfirmDialog(); + const actions = viewModel.actions ?? []; + const editReasonId = `task-card-edit-reason-${task.recordId}`; + const deleteReasonId = `task-card-delete-reason-${task.recordId}`; + const optimisticEditReason = `Saving task ${task.id}; edit is temporarily unavailable.`; + const optimisticDeleteReason = `Saving task ${task.id}; delete is temporarily unavailable.`; + + const restoreTriggerFocus = (): void => { + confirm.triggerRef.current?.focus({ preventScroll: true }); + }; + + const requestDelete = async (): Promise => { + setIsMenuOpen(false); + const confirmed = await confirm.requestConfirm({ + title: "Delete Task", + body: `Delete "${task.title}"? This removes the task card and cannot be undone.`, + confirmLabel: "Delete Task", + cancelLabel: "Cancel", + destructive: true, + }); + + if (confirmed) { + onDelete(task); + return; + } + + restoreTriggerFocus(); + }; + + return ( + <> + + {actions.length > 0 && ( +
+
Execution & navigation
+ {actions.map((action) => { + const ActionIcon = actionIconByKind[action.kind]; + const safeHref = action.href ? getSafeUrl(action.href) : undefined; + const disabledReason = task.isOptimistic + ? `Saving task ${task.id}; ${action.label} is temporarily unavailable.` + : action.disabledReason ?? (action.href && !safeHref + ? `The ${action.label} link is unavailable for task ${task.id}.` + : undefined); + const reasonId = `task-card-action-reason-${task.recordId}-${action.kind}`; + + if (safeHref && !disabledReason) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + setIsMenuOpen(false); + }} + > + + ); + } + + return ( + + + ); + })} +
+ )} + +
+ +
+
Task management
+ { + setIsMenuOpen(false); + onEdit(task); + }} + > + +
+ +
+ +
+
Danger zone
+ { + void requestDelete(); + }} + > + +
+
+ )} + > + + + + + + ); +}); diff --git a/dashboard/src/v2/components/tasks/__tests__/DependencyStatusIndicators.test.tsx b/dashboard/src/v2/components/tasks/__tests__/DependencyStatusIndicators.test.tsx index 9d2530e0ef..9ec67e835c 100644 --- a/dashboard/src/v2/components/tasks/__tests__/DependencyStatusIndicators.test.tsx +++ b/dashboard/src/v2/components/tasks/__tests__/DependencyStatusIndicators.test.tsx @@ -25,32 +25,28 @@ describe("DependencyStatusIndicators", () => { /> ); - // Verify visual text exists (aria-hidden) const task1Elements = container.querySelectorAll('span[aria-hidden="true"]'); expect(Array.from(task1Elements).some(el => el.textContent === "TASK-1")).toBeTruthy(); expect(getByText("Blocked: 5 dependencies need completion")).toBeTruthy(); - // Verify explicit accessible text expect(getByText("Depends on task TASK-1, resolved. Resolved dependency. Dependency completed. Status: completed. Title: Test task 1")).toBeTruthy(); - // Verify sr-only accessible text const srText = getByText((content, element) => { return element?.tagName.toLowerCase() === 'span' && element?.className.includes('sr-only') && content.includes('Depends on task TASK-1'); }); expect(srText).toBeTruthy(); - // Check specific styling classes for visual feedback states const completedIndicator = getByTitle(/Depends on Test task 1 \(Resolved; completed\)/); expect(completedIndicator.className).toContain("text-status-green"); expect(completedIndicator).toHaveAttribute("data-dependency-state", "resolved"); - expect(completedIndicator).toHaveTextContent("Clear"); + expect(Array.from(completedIndicator.querySelectorAll(':scope > span[aria-hidden="true"]')).map((element) => element.textContent)).toEqual(["TASK-1", "Resolved"]); const pendingIndicator = getByTitle(/Depends on Test task 2 \(Blocked; pending\)/); expect(pendingIndicator.className).toContain("text-status-amber"); expect(pendingIndicator.className).not.toContain("border-dashed"); expect(pendingIndicator).toHaveAttribute("data-dependency-state", "blocked"); - expect(pendingIndicator).toHaveTextContent("Blocking"); + expect(Array.from(pendingIndicator.querySelectorAll(':scope > span[aria-hidden="true"]')).map((element) => element.textContent)).toEqual(["TASK-2", "Blocked"]); const blockedIndicator = getByTitle(/Depends on Test task 3 \(QA failed; QA REVIEW FAILED\)/i); expect(blockedIndicator.className).toContain("text-status-red"); @@ -64,7 +60,7 @@ describe("DependencyStatusIndicators", () => { const codingCompleteIndicator = getByTitle(/Depends on Test task 4B \(Ready for QA; coding completed\)/i); expect(codingCompleteIndicator.className).toContain("text-cyan-700"); expect(getByText("Ready for QA")).toBeTruthy(); - expect(codingCompleteIndicator).toHaveTextContent("Blocking"); + expect(Array.from(codingCompleteIndicator.querySelectorAll(':scope > span[aria-hidden="true"]')).map((element) => element.textContent)).toEqual(["TASK-4B", "Ready for QA"]); const unknownIndicator = getByTitle(/Depends on Unknown Task \(missing\) \(Unknown; pending\)/i); expect(unknownIndicator.className).toContain("border-dashed"); @@ -72,6 +68,7 @@ describe("DependencyStatusIndicators", () => { expect(unknownIndicator).toHaveAttribute("data-blocking", "true"); expect(getByText("Unknown")).toBeTruthy(); expect(container.querySelector('[role="list"]')).toHaveAccessibleName("Blocked: 5 dependencies need completion. Task dependencies"); + expect(container.querySelectorAll('[role="list"] > [role="listitem"]')).toHaveLength(6); expect(container.querySelector('[data-motion-control="controlFeedback"]')).toBeTruthy(); expect(container.querySelector('[data-motion-list-reorder="listReorder"]')).toBeTruthy(); }); diff --git a/dashboard/src/v2/components/tasks/__tests__/KanbanTaskCard.integration.test.tsx b/dashboard/src/v2/components/tasks/__tests__/KanbanTaskCard.integration.test.tsx index e18258af32..52bbf6c20e 100644 --- a/dashboard/src/v2/components/tasks/__tests__/KanbanTaskCard.integration.test.tsx +++ b/dashboard/src/v2/components/tasks/__tests__/KanbanTaskCard.integration.test.tsx @@ -2,7 +2,7 @@ /// import { readFileSync } from "node:fs"; import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, cleanup, fireEvent, waitFor, within } from "@testing-library/preact"; +import { render, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import userEvent from "@testing-library/user-event"; import gsap from "gsap"; @@ -286,8 +286,7 @@ describe("KanbanTaskCard Integration", () => { await user.keyboard("{Escape}"); expect(trigger).toHaveFocus(); - expect(getByRole("button", { name: /Edit task TASK-123/i })).toBeInTheDocument(); - expect(getByRole("button", { name: /Delete task TASK-123/i })).toBeInTheDocument(); + expect(getByRole("button", { name: /Open task actions for task TASK-123/i })).toBeInTheDocument(); }); it("uses the shared provider-failure treatment without disabling task actions", () => { @@ -314,8 +313,7 @@ describe("KanbanTaskCard Integration", () => { expect(trigger).toHaveClass("text-status-red"); expect(container.querySelector('[data-qa-state="failed"]')).toBeTruthy(); expect(container.querySelector('[data-qa-icon="failed"]')).toBeTruthy(); - expect(getByRole("button", { name: /Edit task TASK-123/i })).toBeInTheDocument(); - expect(getByRole("button", { name: /Delete task TASK-123/i })).toBeInTheDocument(); + expect(getByRole("button", { name: /Open task actions for task TASK-123/i })).toBeInTheDocument(); }); it.each([ @@ -349,8 +347,7 @@ describe("KanbanTaskCard Integration", () => { await user.click(badge); expect(getByRole("region", { name: "CI workflow details" })).toBeInTheDocument(); - expect(getByRole("button", { name: /Edit task TASK-123/i })).toBeInTheDocument(); - expect(getByRole("button", { name: /Delete task TASK-123/i })).toBeInTheDocument(); + expect(getByRole("button", { name: /Open task actions for task TASK-123/i })).toBeInTheDocument(); if (card) fireEvent.dragStart(card); expect(onDragStart).toHaveBeenCalledTimes(1); @@ -467,7 +464,8 @@ describe("KanbanTaskCard Integration", () => { ], }; - it("renders correctly with live execution fields", () => { + it("renders correctly with live execution fields", async () => { + const user = userEvent.setup(); const { getByRole, getByText } = render( { expect(getByText("ACTIVE")).toBeInTheDocument(); expect(getByText("4m 12s")).toBeInTheDocument(); - expect(getByRole("link", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveTextContent("Live"); + await user.click(getByRole("button", { name: /Open task actions for task TASK-123/i })); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + expect(within(menu).getByRole("menuitem", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveTextContent("Live"); + expect(within(menu).getByRole("menuitem", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveAttribute( + "title", + "Open the live runtime page. Task TASK-123.", + ); + expect(within(menu).getByRole("menuitem", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveAttribute("target", "_blank"); + expect(within(menu).getByRole("menuitem", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveAttribute("rel", "noopener noreferrer"); + expect(within(menu).getByRole("menuitem", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveAttribute( + "title", + "Open pull request in a new tab. Task TASK-123.", + ); + expect(within(menu).getByRole("menuitem", { name: /Open sprint preview for task TASK-123: Implement new feature/i })).toHaveAttribute( + "title", + "Open the sprint preview workspace. Task TASK-123.", + ); // Test that the PR link anchor tag exists by checking for "PR ready" const prLink = getByText("PR ready").closest('a'); expect(prLink).toBeInTheDocument(); expect(prLink).toHaveAttribute("href", "https://github.com/org/repo/pull/42"); + expect(prLink).toHaveAttribute("title", "Open pull request for task TASK-123"); }); it("provides accessible interaction targets and structure", async () => { const user = userEvent.setup(); - const { getByRole, getByTitle, container, getByText } = render( + const { getByRole, getByTitle, container } = render( { /> ); - // Ensure buttons have accessible titles/labels - const editBtn = getByTitle(/Edit task/i); - const deleteBtn = getByTitle(/Delete task/i); - expect(editBtn).toBeInTheDocument(); - expect(deleteBtn).toBeInTheDocument(); - expect(editBtn).toHaveAccessibleName("Edit task TASK-123: Implement new feature"); - expect(deleteBtn).toHaveAccessibleName("Delete task TASK-123: Implement new feature"); - - // Check indicator labels are accessible via their status titles const dependencyIndicator = getByTitle(/Depends on Backend API \(Resolved; completed\)/i); expect(dependencyIndicator).toBeInTheDocument(); @@ -514,21 +520,29 @@ describe("KanbanTaskCard Integration", () => { const card = container.querySelector(".kanban-card"); expect(card).toHaveAttribute("tabIndex", "0"); - // Simulate focus to verify visibility/interaction if (card) { await user.click(card); expect(card).toHaveFocus(); } - const actionsContainer = editBtn.parentElement; - expect(actionsContainer).toHaveClass("kanban-card__actions"); - expect(actionsContainer).toHaveAttribute("aria-label", "Actions for task TASK-123"); - expect(getByText("Rerun")).toBeInTheDocument(); - expect(getByText("Preview")).toBeInTheDocument(); - expect(getByRole("button", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveTextContent("PR pending"); - expect(getByRole("button", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveTextContent("Live idle"); + const actionTrigger = getByRole("button", { name: /Open task actions for task TASK-123: Implement new feature/i }); + expect(actionTrigger).toHaveAttribute("aria-haspopup", "menu"); + expect(actionTrigger).toHaveAttribute("aria-expanded", "false"); + expect(actionTrigger).toHaveClass("kanban-card__action-trigger"); + + await user.click(actionTrigger); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123: Implement new feature/i }); + expect(actionTrigger).toHaveAttribute("aria-expanded", "true"); + expect(within(menu).getByRole("group", { name: "Execution and navigation actions" })).toBeInTheDocument(); + expect(within(menu).getByRole("group", { name: "Task management actions" })).toBeInTheDocument(); + expect(within(menu).getByRole("group", { name: "Destructive task actions" })).toBeInTheDocument(); + expect(within(menu).getByRole("menuitem", { name: /Rerun task TASK-123/i })).toHaveAccessibleDescription("Open Live to rerun task TASK-123."); + expect(within(menu).getByRole("menuitem", { name: /Open sprint preview for task TASK-123/i })).toHaveAttribute("href", "/browser?sprintId=sprint-1"); + const editBtn = within(menu).getByRole("menuitem", { name: /Edit task TASK-123/i }); + const deleteBtn = within(menu).getByRole("menuitem", { name: /Delete task TASK-123/i }); + expect(editBtn).toHaveAccessibleName("Edit task TASK-123: Implement new feature"); + expect(deleteBtn).toHaveAccessibleName("Delete task TASK-123: Implement new feature"); - // Simulate delete click to ensure confirm dialog is requested await user.click(deleteBtn); expect(mockRequestConfirm).toHaveBeenCalledWith(expect.objectContaining({ destructive: true, @@ -536,35 +550,58 @@ describe("KanbanTaskCard Integration", () => { })); }); - it("keeps quick actions mounted at the card bottom and keyboard reachable without hover", async () => { + it("keeps the action trigger persistent and supports complete menu keyboard traversal", async () => { const user = userEvent.setup(); + const onDragStart = vi.fn(); const { container, getByRole } = render( ); const card = container.querySelector(".kanban-card"); - const actionsContainer = container.querySelector(".kanban-card__actions"); + const actionTrigger = getByRole("button", { name: /Open task actions for task TASK-123: Implement new feature/i }); expect(card).toHaveAttribute("tabIndex", "0"); - expect(actionsContainer).toHaveClass("kanban-card__actions"); - expect(actionsContainer).toHaveAttribute("aria-label", "Actions for task TASK-123"); - expect(getByRole("button", { name: /Edit task TASK-123: Implement new feature/i })).toBeInTheDocument(); - expect(getByRole("button", { name: /Delete task TASK-123: Implement new feature/i })).toBeInTheDocument(); - expect(actionsContainer).not.toHaveClass("absolute"); - expect(actionsContainer?.previousElementSibling).toHaveClass("sm:flex-row"); + expect(actionTrigger).toBeVisible(); + expect(actionTrigger).toHaveClass("kanban-card__action-trigger"); + fireEvent.dragStart(actionTrigger); + expect(onDragStart).not.toHaveBeenCalled(); await user.tab(); expect(card).toHaveFocus(); await user.tab(); - expect(getByRole("button", { name: /Rerun task TASK-123: Implement new feature/i })).toHaveFocus(); + expect(actionTrigger).toHaveFocus(); + + await user.keyboard("{ArrowDown}"); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + await waitFor(() => expect(within(menu).getByRole("menuitem", { name: /Open sprint preview/i })).toHaveFocus()); + await user.keyboard("{End}"); + expect(within(menu).getByRole("menuitem", { name: /Delete task TASK-123/i })).toHaveFocus(); + await user.keyboard("{Home}"); + expect(within(menu).getByRole("menuitem", { name: /Open sprint preview/i })).toHaveFocus(); + await user.keyboard("{ArrowDown}"); + expect(within(menu).getByRole("menuitem", { name: /Edit task TASK-123/i })).toHaveFocus(); + await user.keyboard("{ArrowUp}{Escape}"); + await waitFor(() => expect(actionTrigger).toHaveFocus()); + expect(actionTrigger).toHaveAttribute("aria-expanded", "false"); + + await user.keyboard(" "); + await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + fireEvent.mouseDown(document.body); + await waitFor(() => expect(actionTrigger).toHaveAttribute("aria-expanded", "false")); + await waitFor(() => expect(actionTrigger).toHaveFocus()); + + await user.keyboard("{Enter}"); + await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + await user.keyboard("{Escape}"); + await waitFor(() => expect(actionTrigger).toHaveFocus()); - expect(taskCardCss).toContain("@media (any-pointer: fine) and (hover: hover)"); - expect(taskCardCss).toMatch(/\.kanban-card__actions\s*\{[\s\S]*opacity:\s*1;[\s\S]*pointer-events:\s*auto;[\s\S]*transform:\s*translateY\(0\);/); - expect(taskCardCss).toMatch(/@media \(any-pointer: fine\) and \(hover: hover\)\s*\{[\s\S]*\.kanban-card__actions\s*\{[\s\S]*opacity:\s*0;[\s\S]*pointer-events:\s*none;[\s\S]*transform:\s*translateY\(0\.375rem\);/); - expect(taskCardCss).toMatch(/\.kanban-card:hover \.kanban-card__actions,[\s\S]*\.kanban-card:focus \.kanban-card__actions,[\s\S]*\.kanban-card:focus-visible \.kanban-card__actions,[\s\S]*\.kanban-card:focus-within \.kanban-card__actions\s*\{[\s\S]*opacity:\s*1;[\s\S]*pointer-events:\s*auto;[\s\S]*transform:\s*translateY\(0\);/); + expect(taskCardCss).toContain(".kanban-card__action-trigger"); + expect(taskCardCss).toContain("@media (any-pointer: coarse)"); + expect(taskCardCss).not.toContain(".kanban-card__actions"); }); it("renders status transition clearly when a task status updates", async () => { @@ -698,7 +735,7 @@ describe("KanbanTaskCard Integration", () => { const user = userEvent.setup(); mockRequestConfirm.mockImplementationOnce(async () => false); - const { getByTitle } = render( + const { getByRole } = render( { /> ); - const deleteBtn = getByTitle(/Delete task/i); - deleteBtn.focus(); - expect(deleteBtn).toHaveFocus(); - + const actionTrigger = getByRole("button", { name: /Open task actions for task TASK-123/i }); + await user.click(actionTrigger); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + const deleteBtn = within(menu).getByRole("menuitem", { name: /Delete task TASK-123/i }); await user.click(deleteBtn); expect(mockRequestConfirm).toHaveBeenCalled(); expect(onDelete).not.toHaveBeenCalled(); - expect(deleteBtn).toHaveFocus(); + expect(actionTrigger).toHaveFocus(); }); it("provides accurate drag-and-drop screen-reader guidance", async () => { @@ -737,19 +774,25 @@ describe("KanbanTaskCard Integration", () => { expect(card).toHaveFocus(); }); - it("provides task titles in action button accessible labels", () => { + it("provides task titles in action button accessible labels", async () => { + const user = userEvent.setup(); const { getByRole } = render(); - expect(getByRole('button', { name: /Edit task TASK-123: Implement new feature/i })).toBeInTheDocument(); - expect(getByRole('button', { name: /Delete task TASK-123: Implement new feature/i })).toBeInTheDocument(); + await user.click(getByRole("button", { name: /Open task actions for task TASK-123: Implement new feature/i })); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + expect(within(menu).getByRole('menuitem', { name: /Edit task TASK-123: Implement new feature/i })).toBeInTheDocument(); + expect(within(menu).getByRole('menuitem', { name: /Delete task TASK-123: Implement new feature/i })).toBeInTheDocument(); }); - it("keeps unavailable task actions keyboard reachable with explanatory labels", () => { + it("keeps unavailable task actions discoverable with explanatory labels", async () => { + const user = userEvent.setup(); const { getByRole, getByText } = render(); - expect(getByRole("button", { name: /Rerun task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("Open Live to rerun task TASK-123."); - expect(getByRole("button", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("No pull request is available for task TASK-123 yet."); - expect(getByRole("button", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("Live runtime has not started for task TASK-123."); - expect(getByText("Unavailable: Rerun, PR pending, Live idle.")).toBeVisible(); + await user.click(getByRole("button", { name: /Open task actions for task TASK-123/i })); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + expect(within(menu).getByRole("menuitem", { name: /Rerun task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("Open Live to rerun task TASK-123."); + expect(within(menu).getByRole("menuitem", { name: /Open pull request for task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("No pull request is available for task TASK-123 yet."); + expect(within(menu).getByRole("menuitem", { name: /Open live runtime for task TASK-123: Implement new feature/i })).toHaveAccessibleDescription("Live runtime has not started for task TASK-123."); + expect(within(menu).getByText("Open Live to rerun task TASK-123.")).toBeVisible(); expect(getByText("1 dependency blocker")).toBeInTheDocument(); expect(getByText("QA no review")).toBeInTheDocument(); }); @@ -765,20 +808,34 @@ describe("KanbanTaskCard Integration", () => { expect(getByText(/Pull request available. Live runtime 4m 12s, session ACTIVE./i)).toHaveClass("sr-only"); }); - it("marks optimistic quick actions busy and suppresses link activation while saving", () => { + it("marks optimistic menu actions busy and suppresses link activation while saving", async () => { + const user = userEvent.setup(); + const optimisticOnEdit = vi.fn(); + const optimisticOnDelete = vi.fn(); const optimisticViewModel: TaskCardViewModel = { ...mockLiveViewModel, task: { ...mockLiveViewModel.task, isOptimistic: true }, optimisticSavingLabel: "Saving task changes", }; - const { getByRole, getByText } = render(); + const { getByRole, getByText } = render(); - const liveAction = getByRole("button", { name: /Open live runtime for task TASK-123: Implement new feature/i }); + const actionTrigger = getByRole("button", { name: /Open task actions for task TASK-123/i }); + expect(actionTrigger).toHaveAttribute("aria-busy", "true"); + await user.click(actionTrigger); + const menu = await screen.findByRole("menu", { name: /Actions for task TASK-123/i }); + const liveAction = within(menu).getByRole("menuitem", { name: /Open live runtime for task TASK-123: Implement new feature/i }); expect(liveAction).toHaveAttribute("aria-disabled", "true"); expect(liveAction).toHaveAttribute("aria-busy", "true"); expect(liveAction).toHaveAccessibleDescription("Saving task TASK-123; Live is temporarily unavailable."); - expect(getByText("Saving task TASK-123; actions are paused.")).toBeVisible(); + expect(within(menu).getByRole("menuitem", { name: /Edit task TASK-123/i })).toHaveAccessibleDescription("Saving task TASK-123; edit is temporarily unavailable."); + expect(within(menu).getByRole("menuitem", { name: /Delete task TASK-123/i })).toHaveAccessibleDescription("Saving task TASK-123; delete is temporarily unavailable."); + await user.click(within(menu).getByRole("menuitem", { name: /Edit task TASK-123/i })); + await user.click(within(menu).getByRole("menuitem", { name: /Delete task TASK-123/i })); + expect(optimisticOnEdit).not.toHaveBeenCalled(); + expect(optimisticOnDelete).not.toHaveBeenCalled(); + expect(mockRequestConfirm).not.toHaveBeenCalled(); + expect(getByText("Saving task changes")).toBeVisible(); }); it("prevents long metadata strings from overflowing the card horizontally", () => { @@ -810,10 +867,10 @@ describe("KanbanTaskCard Integration", () => { const sourceSpan = container.querySelector('.font-mono.truncate'); expect(sourceSpan).toHaveClass('min-w-0'); - const actionsContainer = container.querySelector('.kanban-card__actions'); - expect(actionsContainer).toHaveClass('kanban-card__actions'); - expect(actionsContainer).not.toHaveClass('absolute'); - expect(actionsContainer).toHaveAttribute("aria-label", "Actions for task TASK-123"); + const actionTrigger = container.querySelector('.kanban-card__action-trigger'); + expect(actionTrigger).toHaveClass('kanban-card__action-trigger'); + expect(actionTrigger).not.toHaveClass('absolute'); + expect(actionTrigger).toHaveAccessibleName("Open task actions for task TASK-123: A very long task title that could potentially blow out the card width if not wrapped correctly with pr-12 or break-words"); }); }); diff --git a/dashboard/src/v2/components/tasks/kanban-task-card.css b/dashboard/src/v2/components/tasks/kanban-task-card.css index ba6099ffe7..e627517587 100644 --- a/dashboard/src/v2/components/tasks/kanban-task-card.css +++ b/dashboard/src/v2/components/tasks/kanban-task-card.css @@ -101,92 +101,171 @@ min-width: 2rem; } -.kanban-card__actions { - opacity: 1; - pointer-events: auto; - transform: translateY(0); - min-height: 2.75rem; +.kanban-card__footer { + min-height: 2rem; contain: layout; - transition: - opacity var(--kanban-card-control-duration, var(--interaction-control-feedback-duration)) var(--kanban-card-control-ease, var(--interaction-control-feedback-ease)), - transform var(--kanban-card-control-duration, var(--interaction-control-feedback-duration)) var(--kanban-card-control-ease, var(--interaction-control-feedback-ease)); } -.kanban-card__actions button, -.kanban-card__actions a { - min-width: 4.75rem; - min-height: 2rem; - white-space: nowrap; +.kanban-card__footer-controls { + min-width: 0; +} + +.kanban-card__action-trigger { + display: inline-flex; + min-height: 2.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: 0.375rem; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.78); + padding: 0.375rem 0.625rem; + color: rgb(71 85 105); + font-size: 0.5625rem; + font-weight: 800; + letter-spacing: 0.12em; + line-height: 1; + text-transform: uppercase; transition: color var(--kanban-card-control-duration, var(--interaction-control-feedback-duration)) var(--kanban-card-control-ease, var(--interaction-control-feedback-ease)), background-color var(--kanban-card-control-duration, var(--interaction-control-feedback-duration)) var(--kanban-card-control-ease, var(--interaction-control-feedback-ease)), + border-color var(--kanban-card-control-duration, var(--interaction-control-feedback-duration)) var(--kanban-card-control-ease, var(--interaction-control-feedback-ease)), transform var(--kanban-card-selection-duration, var(--interaction-selection-movement-duration)) var(--kanban-card-selection-ease, var(--interaction-selection-movement-ease)); } -.kanban-card__action-reason-summary { - flex: 1 1 100%; - max-width: 100%; - min-height: 1rem; +:is(.dark .kanban-card__action-trigger) { + border-color: rgba(255, 255, 255, 0.09); + background: rgba(255, 255, 255, 0.04); + color: rgb(203 213 225); +} + +@media (hover: hover) { + .kanban-card__action-trigger:hover { + border-color: rgba(0, 224, 160, 0.28); + background: rgba(0, 224, 160, 0.08); + color: var(--color-signal-600, rgb(0 94 184)); + } +} + +.kanban-card__action-trigger:active { + transform: scale(0.97); +} + +.kanban-card__action-trigger:focus-visible { + outline: none; + border-color: rgba(0, 224, 160, 0.4); + box-shadow: 0 0 0 3px rgba(0, 224, 160, 0.22); +} + +.kanban-card__action-menu { overflow: hidden; - padding: 0 0.375rem 0.125rem; - color: rgb(100 116 139); +} + +.kanban-card__menu-heading { + padding: 0.25rem 0.625rem 0.125rem; + color: rgb(148 163 184); font-size: 0.5625rem; - font-weight: 700; - line-height: 1rem; - text-align: right; - text-overflow: ellipsis; + font-weight: 800; + letter-spacing: 0.14em; text-transform: uppercase; - white-space: nowrap; } -:is(.dark .kanban-card__action-reason-summary) { - color: rgb(148 163 184); +.kanban-card__menu-separator { + height: 1px; + margin: 0.25rem 0.375rem; + background: rgba(15, 23, 42, 0.07); } -.kanban-card__action { - justify-content: center; +:is(.dark .kanban-card__menu-separator) { + background: rgba(255, 255, 255, 0.08); } -.kanban-card__action-icon { - display: inline-flex; - width: 0.75rem; - min-width: 0.75rem; - align-items: center; - justify-content: center; +.kanban-card__menu-item { + min-height: 2.5rem; + transition: + color var(--interaction-control-feedback-duration) var(--interaction-control-feedback-ease), + background-color var(--interaction-control-feedback-duration) var(--interaction-control-feedback-ease), + transform var(--interaction-selection-movement-duration) var(--interaction-selection-movement-ease); } -.kanban-card__action-label { - display: inline-block; - min-width: 2.25rem; - text-align: left; +.kanban-card__menu-item:not([aria-disabled="true"]):hover { + background: rgba(15, 23, 42, 0.045); + color: rgb(15 23 42); +} + +:is(.dark .kanban-card__menu-item:not([aria-disabled="true"]):hover) { + background: rgba(255, 255, 255, 0.06); + color: rgb(255 255 255); +} + +.kanban-card__menu-item:not([aria-disabled="true"]):active { + transform: scale(0.985); +} + +.kanban-card__menu-item:focus-visible { + box-shadow: inset 0 0 0 2px rgba(0, 224, 160, 0.35); } -.kanban-card__actions [aria-disabled="true"] { +.kanban-card__menu-item[aria-disabled="true"] { cursor: not-allowed; + color: rgb(148 163 184); } -@media (any-pointer: fine) and (hover: hover) { - .kanban-card__actions { - opacity: 0; - pointer-events: none; - transform: translateY(0.375rem); - } +.kanban-card__menu-item--destructive:not([aria-disabled="true"]) { + color: var(--color-status-red, rgb(227 0 15)); +} + +.kanban-card__menu-item--destructive:focus-visible { + box-shadow: inset 0 0 0 2px rgba(227, 0, 15, 0.3); +} - .kanban-card:hover .kanban-card__actions, - .kanban-card:focus .kanban-card__actions, - .kanban-card:focus-visible .kanban-card__actions, - .kanban-card:focus-within .kanban-card__actions { - opacity: 1; - pointer-events: auto; - transform: translateY(0); +.kanban-card__menu-icon { + width: 0.875rem; + min-width: 0.875rem; + height: 0.875rem; + margin-top: 0.125rem; +} + +.kanban-card__menu-reason { + display: block; + margin-top: 0.125rem; + color: rgb(100 116 139); + font-size: 0.625rem; + font-weight: 500; + line-height: 1.35; + text-transform: none; +} + +:is(.dark .kanban-card__menu-reason) { + color: rgb(148 163 184); +} + +@media (any-pointer: coarse) { + .kanban-card__action-trigger, + .kanban-card__menu-item { + min-height: 2.75rem; } } @media (prefers-reduced-motion: reduce) { - .kanban-card__actions, - .kanban-card__actions button, - .kanban-card__actions a, + .kanban-card__action-trigger, + .kanban-card__menu-item, .kanban-card__meta-slots { transition: none; + transform: none; + } + + .kanban-card h4, + .kanban-card:hover h4, + .kanban-card-reduced-motion h4 { + transition: none; + transform: none; } } + +.kanban-card-reduced-motion .kanban-card__action-trigger, +.kanban-card-reduced-motion h4 { + transition: none; + transform: none; +} diff --git a/dashboard/src/v2/pages/__tests__/TasksPage.cards.test.tsx b/dashboard/src/v2/pages/__tests__/TasksPage.cards.test.tsx index e1524f858d..30d8cd7a30 100644 --- a/dashboard/src/v2/pages/__tests__/TasksPage.cards.test.tsx +++ b/dashboard/src/v2/pages/__tests__/TasksPage.cards.test.tsx @@ -137,6 +137,12 @@ const createCiEvent = ( ...overrides, }); +async function openTaskActions(user: ReturnType, taskId: string, title: string): Promise { + const trigger = screen.getByRole("button", { name: `Open task actions for task ${taskId}: ${title}` }); + await user.click(trigger); + return screen.findByRole("menu", { name: `Actions for task ${taskId}: ${title}` }); +} + describe("TasksPage.cards Integration", () => { beforeEach(() => { routerState.searchStr = ""; @@ -233,6 +239,13 @@ describe("TasksPage.cards Integration", () => { // Additional dependency text verification expect(screen.getAllByText("Foundation Setup").length).toBeGreaterThan(0); + const dependentCard = screen.getByLabelText(/^Task T-101: Dependent Feature/i); + const dependencyRow = within(dependentCard).getByRole("listitem", { + name: /Depends on task T-100, resolved\. Resolved dependency\. Dependency completed\./i, + }); + expect(within(dependencyRow).getByText("T-100")).toHaveAttribute("aria-hidden", "true"); + expect(within(dependencyRow).getByText("Resolved")).toHaveAttribute("aria-hidden", "true"); + expect(within(dependentCard).getByRole("button", { name: /Open task actions for task T-101: Dependent Feature/i })).toBeInTheDocument(); expect(screen.getByRole("region", { name: /in progress/i })).toHaveAccessibleDescription(/In Progress lane contains 1 task/i); expect(screen.getByRole("region", { name: /completed/i })).toHaveAccessibleDescription(/Completed lane contains 1 task/i); expect(screen.getByText("Task filters changed. Status All. Priority Any Priority. Showing 20 tasks per lane.")).toHaveClass("sr-only"); @@ -311,7 +324,7 @@ describe("TasksPage.cards Integration", () => { expect(within(unrelatedCard).queryByText("CI failed")).not.toBeInTheDocument(); expect(within(unrelatedCard).getByText("QA no review")).toBeInTheDocument(); expect(reviewedCard).toHaveAttribute("draggable", "true"); - expect(within(reviewedCard).getByRole("button", { name: /Edit task T-100/i })).toBeInTheDocument(); + expect(within(reviewedCard).getByRole("button", { name: /Open task actions for task T-100/i })).toBeInTheDocument(); vi.mocked(useDashboardRuntimeData).mockReturnValue({ execution: { @@ -719,6 +732,14 @@ describe("TasksPage.cards Integration", () => { ); + const workspace = screen.getByRole("region", { name: "Task Board" }); + expect(within(workspace).getByRole("heading", { name: "Tasks" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Task workspace" })).toHaveClass("sr-only"); + expect(screen.getByRole("heading", { name: "Task board" })).toHaveClass("sr-only"); + expect(screen.getByRole("region", { name: /Queued lane/i })).toHaveAccessibleDescription(/Queued lane contains 1 task/i); + expect(screen.getByRole("region", { name: /Completed lane/i })).toHaveAccessibleDescription(/Completed lane contains 1 task/i); + expect(screen.getByRole("button", { name: /Task sprint scope: SPR-1: Sprint One/i })).toHaveAttribute("aria-expanded", "false"); + await user.click(screen.getByRole("tab", { name: "Show completed tasks" })); await waitFor(() => expect(screen.getByText("Release Notes")).toBeInTheDocument()); await waitFor(() => expect(screen.queryByText("Foundation Setup")).not.toBeInTheDocument()); @@ -740,7 +761,11 @@ describe("TasksPage.cards Integration", () => { expect(screen.getByRole("option", { name: /Agent Beta/i })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Close task composer" })); - await user.click(screen.getByRole("button", { name: /Edit task T-100: Foundation Setup/i })); + let taskMenu = await openTaskActions(user, "T-100", "Foundation Setup"); + expect(within(taskMenu).getByRole("group", { name: "Task management actions" })).toBeInTheDocument(); + expect(within(taskMenu).getByRole("group", { name: "Destructive task actions" })).toBeInTheDocument(); + expect(within(taskMenu).getByRole("menuitem", { name: /Rerun task T-100/i })).toHaveAccessibleDescription("Open Live to rerun task T-100."); + await user.click(within(taskMenu).getByRole("menuitem", { name: /Edit task T-100: Foundation Setup/i })); expect(screen.getByRole("region", { name: "Edit task editor" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Refine task" })).toBeInTheDocument(); expect(screen.getByDisplayValue("Foundation Setup")).toBeInTheDocument(); @@ -748,8 +773,15 @@ describe("TasksPage.cards Integration", () => { expect(screen.getByText("Foundation Setup")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Close task composer" })); - await user.click(screen.getByRole("button", { name: /Delete task T-100: Foundation Setup/i })); + taskMenu = await openTaskActions(user, "T-100", "Foundation Setup"); + await user.click(within(taskMenu).getByRole("menuitem", { name: /Delete task T-100: Foundation Setup/i })); expect(screen.getByText(/Delete "Foundation Setup"/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.getByRole("button", { name: /Open task actions for task T-100: Foundation Setup/i })).toHaveFocus()); + expect(deleteTask).not.toHaveBeenCalled(); + + taskMenu = await openTaskActions(user, "T-100", "Foundation Setup"); + await user.click(within(taskMenu).getByRole("menuitem", { name: /Delete task T-100: Foundation Setup/i })); const confirmDeleteButton = screen.getByRole("button", { name: "Hold to Delete Task" }); fireEvent.pointerDown(confirmDeleteButton); await waitFor(() => expect(deleteTask).toHaveBeenCalledWith("task_rec_1"), { timeout: 1500 }); @@ -758,7 +790,8 @@ describe("TasksPage.cards Integration", () => { expect(refreshSprints).toHaveBeenCalled(); }); - it("verifies optimistic task rendering and layout stability", () => { + it("verifies optimistic task rendering, disabled reasons, and layout stability", async () => { + const user = userEvent.setup(); (useProjectData as unknown as any).mockReturnValue({ projects: [{ id: "proj_1", name: "Project Alpha" }], selectedProject: { id: "proj_1", name: "Project Alpha" }, @@ -796,6 +829,14 @@ describe("TasksPage.cards Integration", () => { expect(card).toHaveClass("border-dashed"); expect(card).toHaveClass("opacity-70"); expect(card).toHaveTextContent("Saving task changes"); + const trigger = screen.getByRole("button", { name: /Open task actions for task T-NEW: Optimistic Title/i }); + expect(trigger).toHaveAttribute("aria-busy", "true"); + await user.click(trigger); + const menu = await screen.findByRole("menu", { name: /Actions for task T-NEW: Optimistic Title/i }); + expect(within(menu).getByRole("menuitem", { name: /Edit task T-NEW/i })).toHaveAccessibleDescription("Saving task T-NEW; edit is temporarily unavailable."); + expect(within(menu).getByRole("menuitem", { name: /Delete task T-NEW/i })).toHaveAccessibleDescription("Saving task T-NEW; delete is temporarily unavailable."); + await user.click(within(menu).getByRole("menuitem", { name: /Delete task T-NEW/i })); + expect(screen.queryByRole("dialog", { name: "Delete Task" })).not.toBeInTheDocument(); }); it("submits edited tasks with the selected worker-agent preset", async () => { @@ -839,7 +880,8 @@ describe("TasksPage.cards Integration", () => { ); - await user.click(screen.getByRole("button", { name: /Edit task T-100: Foundation Setup/i })); + const taskMenu = await openTaskActions(user, "T-100", "Foundation Setup"); + await user.click(within(taskMenu).getByRole("menuitem", { name: /Edit task T-100: Foundation Setup/i })); expect(screen.getByRole("region", { name: "Edit task editor" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Worker Agent" })); diff --git a/docs-web/content/docs/architecture-sprint-rollbacks.mdx b/docs-web/content/docs/architecture-sprint-rollbacks.mdx new file mode 100644 index 0000000000..383f362a14 --- /dev/null +++ b/docs-web/content/docs/architecture-sprint-rollbacks.mdx @@ -0,0 +1,24 @@ +# Sprint Rollbacks + +Code UX models a rollback as a new sprint, not as destructive history editing. The original sprint remains auditable, while the rollback receives its own branch, tasks, execution history, and visual identity. Remote projects deliver the branch through a pull request; local projects merge it locally without creating one. + +## Choosing the execution path + +Before creation, Code UX checks the completed source sprint, Git mode, later sprint activity, and the source merge at the tip of the default branch. + +- **Automatic rollback** is offered only for a proven isolated latest merge with no later sprint work. Code UX reverts that merge in a detached worktree and enforces a hard no-dispatch boundary for its settled audit task. Remote mode pushes the dedicated rollback branch and completes it through a green pull request. Local mode keeps the branch local and merges it into the configured default branch without a pull request. +- **Agent-assisted rollback** is used when later work may depend on the source, merge history is ambiguous, a deterministic revert conflicts, or you enter custom instructions. + +Entering instructions always selects the agent path. This is how you request a partial rollback such as “remove only feature XY but keep the migration.” The agent is told to inspect dependencies, preserve compatible work, and update tests. It pushes only in remote mode; local mode commits to the rollback branch without remote access. + +## Delivery by Git mode + +Remote rollback sprints force live PR tracking even when ordinary sprint PR monitoring is disabled. Automatic rollbacks use green-check auto-merge; agent-assisted rollbacks retain the configured merge policy, with `OFF` promoted to `CREATE_PR`. A remote rollback sprint completes only after the Git host reports the PR merged. + +Local rollback sprints do not fetch, push, or create a PR. Code UX uses its standard isolated local finalization worktree to merge the rollback branch into the configured default branch, preserving a dirty visible checkout and surfacing merge conflicts through attention handling. The sprint completes only after the local merge succeeds. + +## Dashboard identity + +Rollback gallery cells and ledger rows use an orange treatment, a rollback badge, and distinct action copy. Their normal run status, task progress, CI state, review state, and human-attention indicators remain visible. + +See [Sprints](/docs/user-dashboard-sprints) for the operator workflow. diff --git a/docs-web/content/docs/architecture-virtual-workers.mdx b/docs-web/content/docs/architecture-virtual-workers.mdx index 9eaf26285a..5941fdc5e6 100644 --- a/docs-web/content/docs/architecture-virtual-workers.mdx +++ b/docs-web/content/docs/architecture-virtual-workers.mdx @@ -124,7 +124,7 @@ Each dispatch operates on its own Git worktree under `/.worktrees/` for the explicit sprint feature branch or the effective runtime git default branch, never the host repo's current checkout. If that remote tracking ref or fallback cannot be prepared, planning fails instead of falling back to a stale local branch. Restart and Continue reuse the preserved snapshot workspace so cancelled or interrupted provider sessions can still resume. +Docker-backed planning uses a read-only snapshot workspace instead of a mutable task worktree. In `REMOTE` git mode, fresh planning invocations refresh `origin` and check out only `origin/` for the explicit sprint feature branch or the effective runtime git default branch, never the host repo's current checkout. If that remote tracking ref or fallback cannot be prepared, planning fails instead of falling back to a stale local branch. In `LOCAL` git mode, sprint branch allocation and preflight use local heads only and never fetch, inspect, fast-forward from, or push to `origin`, even if the repository has a remote configured. Restart and Continue reuse the preserved snapshot workspace so cancelled or interrupted provider sessions can still resume. Provider CLI workspace preparation is centralized through `InvocationWorkspacePreparer`. Its shared provider-invocation option builder constructs snapshot checkout, git policy, and fresh/continue lifecycle values for Docker provider calls, while its continuation resolver locates preserved workspaces and their current branches. Fresh Docker invocations in `REMOTE` git mode use explicit remote refs only: planning, project setup, dashboard/chat replies, worker inbox replies, node-flow provider prompts, QA review snapshots, task coding, QA follow-up, CI autofix, and merge-conflict repair all materialize from `origin/` refs rather than local branches or the host repo's current checkout. Dashboard/chat replies resolve dashboard settings with the project scope before building this policy, so local Git projects keep `LOCAL` snapshot behavior and do not require `origin/`. Continuation/restart flows may reuse a preserved workspace for provider-session continuity; if a preserved workspace is missing and a new workspace must be materialized, the same remote-only branch policy applies. diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 1ead876cae..4878ebd1bf 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -52,6 +52,8 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | `GET` | `/api/projects/:projectId/sprints` | List. | | `POST` | `/api/projects/:projectId/sprints` | Create. | | `PATCH` | `/api/sprints/:sprintId` | Update. | +| `POST` | `/api/sprints/:sprintId/complete` | Runtime-aware manual completion; force-cancels an active sprint run before persisting `completed`. | +| `POST` | `/api/sprints/:sprintId/qa-pass` | Record a manual sprint-level QA pass and resolve its matching sprint QA handoff. | | `DELETE` | `/api/sprints/:sprintId` | Delete. | | `POST` | `/api/projects/:projectId/sprints/import` | Import from a markdown bundle. | | `GET` | `/api/projects/:projectId/sprints/:sprintId/export` | Export as a markdown bundle. | diff --git a/docs-web/content/docs/developer-orchestration-debugging.mdx b/docs-web/content/docs/developer-orchestration-debugging.mdx index c797cee125..22150b43ff 100644 --- a/docs-web/content/docs/developer-orchestration-debugging.mdx +++ b/docs-web/content/docs/developer-orchestration-debugging.mdx @@ -36,7 +36,7 @@ The Playwright workflow keeps lightweight legacy aggregate jobs named `Playwrigh In LOCAL git mode, recovered worker-branch evidence is dependency state: downstream DAG tasks stay blocked until the parent branch has merged into the sprint feature branch or the parent is proven to have no merge work. -Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. +Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If a restart lands after the feature ref is updated but before merge metadata is persisted, recovery proves the surviving worker branch is already an ancestor of the feature branch and restores `isMerged`, `MERGED`, and the `merged_branch` event before downstream tasks unlock. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. Host-execution DAG tasks export worker output through an isolated temporary Git index. The exporter uses Git's ignore-aware changed-path discovery, stages modified/deleted/untracked paths into that index, and emits a cached binary diff so parent-created files are present before dependent tasks unlock without committing runtime caches. Host worktrees use an absolute temporary index path, while Docker workspaces keep a container-relative path, so Git for Windows and container Git both write the export index in the intended workspace. diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index 4ed5cb7d70..de2ddddbd5 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -234,7 +234,7 @@ export const docsRegistry: Record = { path: '/docs/user-dashboard-tasks', section: 'User Guide', title: "Tasks", - description: "The Tasks page (/tasks) is a Kanban-style task board for the active project. It organizes tasks into Queued, In Progress, and Completed lanes, with sprint scope, status, priority, and search controls above the board.", + description: "The Tasks page (/tasks) is a Kanban-style task board for the active project. It organizes tasks into Queued, In Progress, and Completed lanes, with sprint scope, status, priority, and visible-card controls above the b...", }, 'user-dashboard-live-session': { id: 'user-dashboard-live-session', diff --git a/docs-web/content/docs/settings-quality-assurance.mdx b/docs-web/content/docs/settings-quality-assurance.mdx index 19b62e5231..8b8b1cd591 100644 --- a/docs-web/content/docs/settings-quality-assurance.mdx +++ b/docs-web/content/docs/settings-quality-assurance.mdx @@ -55,6 +55,7 @@ If the saved setting does not appear to take effect: - A fix continuation created by the review that reaches the configured cap gets one final verification review. A CLI continuation with no patch and no commits ahead is treated as `follow_up_no_progress` and applies the exhaustion policy immediately; repeated continuations cannot extend the budget indefinitely. - Recovered failed, cancelled, or errored QA attempts retry only within the bounded infrastructure grace. All terminal attempts count toward the hard ceiling, so repeated container loss eventually opens the configured handoff. - Sprint QA review limits count review cycles, not the number of findings in each earlier cycle. The final configured cycle is verification-only: if it does not pass, Code UX opens one sprint-scoped human handoff and does not create another automatic follow-up batch. Completed follow-up work cannot bypass that exhausted-budget handoff merely because it changed the task snapshot. +- After a person reviews a blocked sprint result, **Mark QA Pass** in the Sprints page action menu creates a durable manual passing verdict and resolves only the sprint-level QA handoff. The control is disabled while an automated sprint review is running; it does not approve task-level QA failures or unrelated attention. ## Related Documentation diff --git a/docs-web/content/docs/user-dashboard-tasks.mdx b/docs-web/content/docs/user-dashboard-tasks.mdx index 57f5d56dce..39f74c4d16 100644 --- a/docs-web/content/docs/user-dashboard-tasks.mdx +++ b/docs-web/content/docs/user-dashboard-tasks.mdx @@ -1,6 +1,6 @@ # Tasks -The **Tasks** page (`/tasks`) is a Kanban-style task board for the active project. It organizes tasks into **Queued**, **In Progress**, and **Completed** lanes, with sprint scope, status, priority, and search controls above the board. +The **Tasks** page (`/tasks`) is a Kanban-style task board for the active project. It organizes tasks into **Queued**, **In Progress**, and **Completed** lanes, with sprint scope, status, priority, and visible-card controls above the board. Use it when you want to review planned work, create or edit a task, check dependency blockers, or choose how a specific task should be executed. @@ -14,16 +14,24 @@ If project or sprint selection requests overlap, only the newest response may up ## Board workflow -The board keeps the current sprint scope and filters visible while you work: +The page header keeps project and sprint context beside the primary **New Task** command. Immediately below it, the board uses two compact operational surfaces: + +- **Task board controls** keeps sprint scope, status, priority, and visible-card count together. It stacks on phones, wraps into two columns on tablets, and becomes one rail on wide screens. Long sprint names stay inside the selector instead of widening the page. +- **Task board overview** shows the filtered total plus running, completed, and critical counts. When a sprint is selected, it also shows the sprint date, percentage complete, an accessible completed-task progress bar, and the queued/running/completed distribution. + +The controls keep the current sprint scope and filters visible while you work: - **Sprint scope** narrows the board to all tasks or one sprint. - **Status and priority filters** refine the visible cards without losing the current board context. -- **Search** matches task titles and task text. - **Visible count controls** limit how many cards render in each lane for larger projects. ## Columns -Task cards show the task title, status, priority, dependency state, downstream dependents, executor metadata, recent activity context, optional self-reflection ratings, and available actions. Dragging a card to another lane changes its status when that transition is available. +Each lane is a named region whose accessible name includes its count, such as **In Progress lane, 2 tasks**. The board uses one column on phones, two columns when lanes remain readable, and three columns on wide screens. Lane frames and drop surfaces keep a stable height while filters settle, data loads, or a lane is empty, so adjacent work does not collapse or jump. Loading lanes use card-shaped skeletons; empty lanes explain whether the result comes from filters, the selected sprint, or project-wide scope; refresh failures appear as an assertive board update message without removing the current board context. + +Reduced-motion mode removes board, card, selector, progress, menu, and drop-target movement and disables pointer dragging. Static labels, borders, focus rings, progress values, lane counts, empty states, action availability reasons, and drag-disabled guidance remain available. + +Each task card shows its task identifier, title, status, and priority first. Compact metadata can then show a non-default executor or worker agent, session state and identifier, QA and CI state, dependency blocker count, optimistic saving state, source and assignee, runtime duration, pull-request state, creation or live-start time, and an optional self-reflection rating. The footer always keeps the task-labelled **Actions** trigger visible. Dragging a card to another lane changes its status when that transition is available. When a worker reports a task-run self-reflection rating, the shared rating badge appears in the compact card metadata near the task id, status, and priority. It shows the overall `overallRating` as a numeric score with a compact 5-star meter. Hovering the badge, or focusing it with the keyboard, opens a viewport-positioned details panel with each section from `sections`: the section label, matching stars, numeric rating, and any note captured by the worker. Tasks without a captured rating, including older tasks that never produced one, do not render an empty badge slot. @@ -84,21 +92,29 @@ Validation keeps the current draft visible. If a required field is missing, the ## Dependencies -Dependencies determine whether a task is ready to run. A task with incomplete dependencies remains blocked until those dependencies move to a completed state. Cards show dependency blockers and downstream dependent tasks so you can see both what a task waits on and what it unblocks. +Dependencies determine whether a task is ready to run. A task with any dependency that is not completed remains blocked, including one whose dependency is **Ready for QA**. Cards use an amber blocker summary when work is blocked and a green clear summary when every dependency is completed. Each compact dependency row visibly contains only its task identifier and one normalized status: **Resolved**, **Ready for QA**, **In progress**, **QA failed**, **Blocked**, or **Unknown**. The complete dependency title, raw status, and blocking or resolved meaning remain available to assistive technology and in the row tooltip. The editor prevents invalid dependency selections such as dependency cycles. When a dependency cannot be selected, the reason is shown in the editor rather than silently hiding the option. ## Task actions -Task cards expose actions for the work that is available in the current state: +Every task card keeps a visible, task-labelled **Actions** trigger in its footer. Activating it opens three groups: + +- **Execution & navigation** contains **Rerun**, **Preview**, the eligible **PR** or **PR pending** entry, and **Live** or **Live idle**. Rerun is an informational disabled item that directs you to Live; it does not dispatch a run from the Tasks page. Preview opens the sprint preview when the task belongs to a sprint. PR opens an existing pull request in a new tab, while PR pending explains that no pull request is available yet. Live opens the runtime page only after runtime context exists. +- **Task management** contains **Edit**, which opens the full inline task editor for content, dependencies, executor mode, and worker-agent selection. +- **Danger zone** contains **Delete**. + +The menu opens with click, Enter, Space, Arrow Up, or Arrow Down. Opening focuses the first enabled action; Arrow Up opens at the last enabled action. Arrow keys wrap between enabled actions, Home and End jump to the first and last enabled actions, and Enter or Space activates the focused action. Escape or clicking outside closes the menu and restores focus to the trigger. + +Unavailable actions included by the current task and project settings stay visible but inert, with the reason directly beneath the action label. For example, Preview explains when a task has no sprint, Live explains when runtime has not started, and every action explains when an optimistic save temporarily makes it unavailable. The trigger remains available while a card is saving so these reasons are still discoverable, while duplicate mutations remain suppressed. When project settings disable task pull requests and the task has no existing PR, the menu omits the PR entry instead of showing a misleading pending action. + +Edit does not ask for confirmation: it opens the editor with the current task values, and Cancel closes the editor without saving. Save keeps the selected sprint scope and active filters in place. Delete closes the menu and opens a **Delete Task** confirmation that names the task, states that removal cannot be undone, and requires holding the destructive button until confirmation completes. Cancelling or pressing Escape leaves the task in place and returns focus to that card's **Actions** trigger. + +## Responsive and keyboard behavior -- **Edit** opens the full task editor for content, dependencies, executor mode, and worker-agent selection. -- **Rerun** starts a fresh execution attempt for the task when rerun is available. -- **Preview** opens the task's available runtime preview when one exists. -- **Live** opens live task context when runtime details are available. -- **Delete** removes the task after confirmation. +On wide screens, the three lanes share one row when space permits. On phones, they stack vertically and card titles, dependency identifiers, and action menus remain within the page width without creating document-level horizontal scrolling. Menus are positioned inside the current viewport even when their card is near an edge. -Unavailable actions stay visible with a reason so the board layout remains stable and keyboard users can understand why an action cannot run. +The board keeps accessible lane counts and status announcements during loading, filtering, optimistic saves, realtime refreshes, and empty or error states. Opening a task menu with the keyboard moves focus to its first enabled action; `Escape` returns focus to the same task-labelled trigger. Cancelling deletion also returns focus to that trigger, and reduced-motion mode preserves the same status text and focus treatment while disabling drag movement. ## Status legend diff --git a/docs-web/developer/orchestration-debugging.md b/docs-web/developer/orchestration-debugging.md index c797cee125..22150b43ff 100644 --- a/docs-web/developer/orchestration-debugging.md +++ b/docs-web/developer/orchestration-debugging.md @@ -36,7 +36,7 @@ The Playwright workflow keeps lightweight legacy aggregate jobs named `Playwrigh In LOCAL git mode, recovered worker-branch evidence is dependency state: downstream DAG tasks stay blocked until the parent branch has merged into the sprint feature branch or the parent is proven to have no merge work. -Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. +Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If a restart lands after the feature ref is updated but before merge metadata is persisted, recovery proves the surviving worker branch is already an ancestor of the feature branch and restores `isMerged`, `MERGED`, and the `merged_branch` event before downstream tasks unlock. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. Host-execution DAG tasks export worker output through an isolated temporary Git index. The exporter uses Git's ignore-aware changed-path discovery, stages modified/deleted/untracked paths into that index, and emits a cached binary diff so parent-created files are present before dependent tasks unlock without committing runtime caches. Host worktrees use an absolute temporary index path, while Docker workspaces keep a container-relative path, so Git for Windows and container Git both write the export index in the intended workspace. diff --git a/docs-web/user/dashboard/tasks.md b/docs-web/user/dashboard/tasks.md index 57f5d56dce..39f74c4d16 100644 --- a/docs-web/user/dashboard/tasks.md +++ b/docs-web/user/dashboard/tasks.md @@ -1,6 +1,6 @@ # Tasks -The **Tasks** page (`/tasks`) is a Kanban-style task board for the active project. It organizes tasks into **Queued**, **In Progress**, and **Completed** lanes, with sprint scope, status, priority, and search controls above the board. +The **Tasks** page (`/tasks`) is a Kanban-style task board for the active project. It organizes tasks into **Queued**, **In Progress**, and **Completed** lanes, with sprint scope, status, priority, and visible-card controls above the board. Use it when you want to review planned work, create or edit a task, check dependency blockers, or choose how a specific task should be executed. @@ -14,16 +14,24 @@ If project or sprint selection requests overlap, only the newest response may up ## Board workflow -The board keeps the current sprint scope and filters visible while you work: +The page header keeps project and sprint context beside the primary **New Task** command. Immediately below it, the board uses two compact operational surfaces: + +- **Task board controls** keeps sprint scope, status, priority, and visible-card count together. It stacks on phones, wraps into two columns on tablets, and becomes one rail on wide screens. Long sprint names stay inside the selector instead of widening the page. +- **Task board overview** shows the filtered total plus running, completed, and critical counts. When a sprint is selected, it also shows the sprint date, percentage complete, an accessible completed-task progress bar, and the queued/running/completed distribution. + +The controls keep the current sprint scope and filters visible while you work: - **Sprint scope** narrows the board to all tasks or one sprint. - **Status and priority filters** refine the visible cards without losing the current board context. -- **Search** matches task titles and task text. - **Visible count controls** limit how many cards render in each lane for larger projects. ## Columns -Task cards show the task title, status, priority, dependency state, downstream dependents, executor metadata, recent activity context, optional self-reflection ratings, and available actions. Dragging a card to another lane changes its status when that transition is available. +Each lane is a named region whose accessible name includes its count, such as **In Progress lane, 2 tasks**. The board uses one column on phones, two columns when lanes remain readable, and three columns on wide screens. Lane frames and drop surfaces keep a stable height while filters settle, data loads, or a lane is empty, so adjacent work does not collapse or jump. Loading lanes use card-shaped skeletons; empty lanes explain whether the result comes from filters, the selected sprint, or project-wide scope; refresh failures appear as an assertive board update message without removing the current board context. + +Reduced-motion mode removes board, card, selector, progress, menu, and drop-target movement and disables pointer dragging. Static labels, borders, focus rings, progress values, lane counts, empty states, action availability reasons, and drag-disabled guidance remain available. + +Each task card shows its task identifier, title, status, and priority first. Compact metadata can then show a non-default executor or worker agent, session state and identifier, QA and CI state, dependency blocker count, optimistic saving state, source and assignee, runtime duration, pull-request state, creation or live-start time, and an optional self-reflection rating. The footer always keeps the task-labelled **Actions** trigger visible. Dragging a card to another lane changes its status when that transition is available. When a worker reports a task-run self-reflection rating, the shared rating badge appears in the compact card metadata near the task id, status, and priority. It shows the overall `overallRating` as a numeric score with a compact 5-star meter. Hovering the badge, or focusing it with the keyboard, opens a viewport-positioned details panel with each section from `sections`: the section label, matching stars, numeric rating, and any note captured by the worker. Tasks without a captured rating, including older tasks that never produced one, do not render an empty badge slot. @@ -84,21 +92,29 @@ Validation keeps the current draft visible. If a required field is missing, the ## Dependencies -Dependencies determine whether a task is ready to run. A task with incomplete dependencies remains blocked until those dependencies move to a completed state. Cards show dependency blockers and downstream dependent tasks so you can see both what a task waits on and what it unblocks. +Dependencies determine whether a task is ready to run. A task with any dependency that is not completed remains blocked, including one whose dependency is **Ready for QA**. Cards use an amber blocker summary when work is blocked and a green clear summary when every dependency is completed. Each compact dependency row visibly contains only its task identifier and one normalized status: **Resolved**, **Ready for QA**, **In progress**, **QA failed**, **Blocked**, or **Unknown**. The complete dependency title, raw status, and blocking or resolved meaning remain available to assistive technology and in the row tooltip. The editor prevents invalid dependency selections such as dependency cycles. When a dependency cannot be selected, the reason is shown in the editor rather than silently hiding the option. ## Task actions -Task cards expose actions for the work that is available in the current state: +Every task card keeps a visible, task-labelled **Actions** trigger in its footer. Activating it opens three groups: + +- **Execution & navigation** contains **Rerun**, **Preview**, the eligible **PR** or **PR pending** entry, and **Live** or **Live idle**. Rerun is an informational disabled item that directs you to Live; it does not dispatch a run from the Tasks page. Preview opens the sprint preview when the task belongs to a sprint. PR opens an existing pull request in a new tab, while PR pending explains that no pull request is available yet. Live opens the runtime page only after runtime context exists. +- **Task management** contains **Edit**, which opens the full inline task editor for content, dependencies, executor mode, and worker-agent selection. +- **Danger zone** contains **Delete**. + +The menu opens with click, Enter, Space, Arrow Up, or Arrow Down. Opening focuses the first enabled action; Arrow Up opens at the last enabled action. Arrow keys wrap between enabled actions, Home and End jump to the first and last enabled actions, and Enter or Space activates the focused action. Escape or clicking outside closes the menu and restores focus to the trigger. + +Unavailable actions included by the current task and project settings stay visible but inert, with the reason directly beneath the action label. For example, Preview explains when a task has no sprint, Live explains when runtime has not started, and every action explains when an optimistic save temporarily makes it unavailable. The trigger remains available while a card is saving so these reasons are still discoverable, while duplicate mutations remain suppressed. When project settings disable task pull requests and the task has no existing PR, the menu omits the PR entry instead of showing a misleading pending action. + +Edit does not ask for confirmation: it opens the editor with the current task values, and Cancel closes the editor without saving. Save keeps the selected sprint scope and active filters in place. Delete closes the menu and opens a **Delete Task** confirmation that names the task, states that removal cannot be undone, and requires holding the destructive button until confirmation completes. Cancelling or pressing Escape leaves the task in place and returns focus to that card's **Actions** trigger. + +## Responsive and keyboard behavior -- **Edit** opens the full task editor for content, dependencies, executor mode, and worker-agent selection. -- **Rerun** starts a fresh execution attempt for the task when rerun is available. -- **Preview** opens the task's available runtime preview when one exists. -- **Live** opens live task context when runtime details are available. -- **Delete** removes the task after confirmation. +On wide screens, the three lanes share one row when space permits. On phones, they stack vertically and card titles, dependency identifiers, and action menus remain within the page width without creating document-level horizontal scrolling. Menus are positioned inside the current viewport even when their card is near an edge. -Unavailable actions stay visible with a reason so the board layout remains stable and keyboard users can understand why an action cannot run. +The board keeps accessible lane counts and status announcements during loading, filtering, optimistic saves, realtime refreshes, and empty or error states. Opening a task menu with the keyboard moves focus to its first enabled action; `Escape` returns focus to the same task-labelled trigger. Cancelling deletion also returns focus to that trigger, and reduced-motion mode preserves the same status text and focus treatment while disabling drag movement. ## Status legend diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 7c6d108cf0..25ba81934e 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -416,7 +416,7 @@ Legacy runtime: - Sprint cells now surface a QA-reviewed indicator with an expandable overlay section inside the created column, and allow marking sprints completed directly from the cell menu - Sprint card and ledger action menus expose separate `Mark Completed` and `Mark QA Pass` controls. `Mark Completed` uses a runtime-aware endpoint: if a sprint run is still active, Code UX force-cancels its dispatches, provider invocations, leases, and runtime rows before persisting the sprint as completed, preventing the live run from projecting `running` back over the manual choice. `Mark QA Pass` persists a completed sprint-level QA run with outcome `pass`, reviewer `Manual QA`, and dashboard provenance, then resolves only an active sprint-scoped QA human handoff. It is disabled while sprint QA is actively reviewing and disappears once the latest sprint QA verdict is already passing. - Task rows and Live task cards now surface task-level QA review badges from the latest task QA run, including a running indicator while QA review is in progress, and the same task records carry an optional latest `selfReflectionRating` payload when a completed task run persisted one. Rated tasks expose `overallRating` plus per-section `sections`; unrated or historical tasks without a captured rating omit the field and do not show a placeholder badge. -- The Tasks page sprint scope selector uses a keyboard-accessible listbox pattern with selected, open, loading, and empty option state, arrow/Home/End navigation, Escape close, outside-click close, and trigger focus restoration. Task board status and priority filters keep the current cards visible during the short filter transition, then announce the settled result count through a polite live region. Task board lanes render as named regions with count summaries, drop-target feedback, reduced-motion drag-disabled copy, and status regions for loading, empty, and error states; Kanban cards expose task id/title/status/priority, dependency blockers, optimistic saving, session, preview, PR, live runtime, rerun availability, duration, QA review context, self-reflection rating context, and screen-reader drag guidance in stable accessible text while keeping drag-and-drop pointer-only. Creating or editing a task opens a named editor viewbox inside the task workspace: it sits to the right of the board on wide screens, becomes the primary full-width region on narrow screens, keeps the selected sprint scope plus active board filters intact, and can persist a task-level `agentPresetId` worker-agent override. Card quick actions sit in the bottom footer below metadata/dependency indicators; fine-pointer layouts reserve the action tray and reveal it on hover or keyboard focus, while touch/coarse-pointer layouts keep actions visible. +- The Tasks page keeps a stable intro, controls, overview, feedback, and lane hierarchy. Its sprint scope selector uses a keyboard-accessible listbox with selected, open, loading, and empty option states, arrow/Home/End navigation, Escape and outside-click dismissal, and trigger focus restoration. Sprint scope, status, priority, and visible-card count share a named responsive control rail; the separate overview exposes filtered totals plus selected-sprint completion. Filter transitions preserve the current cards until the next result settles, and realtime refreshes retain sprint scope and filters. Named lanes keep stable framed drop surfaces and accessible loading, empty, error, and count feedback. Cards preserve optimistic state plus dependency, QA, CI, PR, duration, session, and live-runtime metadata while updates settle. Creating or editing a task opens a named editor viewbox inside the workspace, beside the board on wide screens and above it at full width on narrow screens, without discarding sprint scope, filters, or the current card state. Every card keeps a visible task-labelled **Actions** trigger; its portal menu groups execution/navigation, Edit, and Delete without a hover-only action rail. - Rendered markdown previews use near-black body, heading, list, blockquote, and table text in light mode while preserving slate/white dark-mode text and signal-colored links/code. - Live task cards now include `Edit` and `Force complete` actions: - `Edit` deep-links to `/tasks?taskId=&sprintId=` so operators can open the task editor directly from the live surface. @@ -505,8 +505,7 @@ Legacy runtime: - Tasks page task-card PR affordances use resolved project settings from `GET /api/projects/:projectId/settings/effective`: `PR pending` metadata and pending PR actions are hidden when effective project git settings disable task PR creation, including `git.autoCreatePr` off or `git.githubMode` set to `LOCAL`, while runtime-enriched PR links remain visible for existing pull requests whenever a URL exists. - The create/edit task editor announces validation through the shared action feedback region, focuses and scrolls the first invalid required field into view, and exposes title, description, markdown prompt, status, priority, executor, dependencies, and worker-agent selection as labeled controls. Dependency filtering reports result-count changes through a polite live region and preserves selected dependencies when the current filter hides them. The worker-agent selector saves the built-in worker as no override and configured presets as the task's `agentPresetId`. - On a fresh installation, the Tasks page replaces the old generic project/sprint/task database message with a polished task-scope placeholder; the project action opens the shared Add Project dialog and the sprint action routes operators to the Sprints page before the kanban controls appear. -- Task cards now explicitly show downstream dependent tasks as readable metadata tags. -- Task cards keep the premium glass layout with pointer-driven tilt, status wave, border trace, compact executor/time metadata, and dependency status badges. Quick actions for edit, delete, rerun, preview, PR, and live runtime are visually revealed on hover or keyboard focus instead of being persistently shown, and they remain keyboard accessible with fixed hit targets and task-specific labels. Low-value visible metadata such as the default `Auto` executor and the pointer-only drag helper chip are omitted from cards; screen-reader drag guidance, dependency blockers, QA review, optimistic saving, PR-disabled pending states, and drag-disabled context remain available through accessible text, badges, or labels. +- Task cards keep compact status, priority, execution, dependency, QA, CI, PR, runtime, source, assignee, and timestamp metadata without promoting the default `Auto` executor or a pointer-only drag helper. Dependency rows visibly contain only the task identifier and normalized state (`Resolved`, `Ready for QA`, `In progress`, `QA failed`, `Blocked`, or `Unknown`); the full title and blocking context stay in the accessible name and tooltip. The persistent **Actions** trigger opens **Execution & navigation**, **Task management**, and **Danger zone** groups. Disabled and optimistic items stay inert with a visible reason below the label, keyboard traversal skips them, Escape or outside dismissal restores trigger focus, Edit opens the inline editor, and Delete uses the shared destructive confirmation with focus restored after cancellation. - Navigating from a sprint cell into `View Tasks` preselects that sprint when it belongs to the active project instead of leaving the board on `All Sprints`. - Tasks page sprint deep links are local route filters. They do not change the global project selector, and stale `?sprintId=` values from another project are discarded when the navbar project changes. - Selecting sprint scope from the Tasks page body remains project-local: the selector updates `?sprintId=` and persists the selected sprint through `PUT /api/projects/:projectId/selected-sprint` for the active project only. @@ -1033,7 +1032,7 @@ This dashboard enforces accessibility best practices to ensure an inclusive expe - **Reduced Motion**: Component animations using GSAP and Tailwind respect user preferences via the `prefers-reduced-motion` media query and the explicit dashboard reduced-motion root attribute. Features like the Kinetic Dock immediately snap indicator positions without transition. Decorative background loops (e.g., CanvasBackground) and GSAP ticker updates (e.g., Sprint Boat Race) instantly skip interpolations, disable hover magnetism, remove visual ripples, and substitute animated motion with immediate static state reflections such as rings, halos, badges, values, and selected states to preserve functional state comprehension. - **Feedback Surfaces**: Feedback surfaces (like `ToastProvider` and `ActionFeedbackRegion`) separate polite status announcements (success, warning, info) from assertive error announcements. Action buttons (Dismiss, Retry) use concise accessible names without repeating the entire dynamic message. When a focused feedback control removes itself, focus is predictably restored to a sensible fallback (e.g., `[role="main"]` or `body`). Live regions for notifications and status banners are kept in the DOM to ensure reliable screen reader announcements when their text changes. - **Task Board State Ownership:** To prevent lane mapping drift across views, `dashboard/src/v2/lib/task-board-state.ts` is the strict single source of truth for all task status to lane derivations (via `getTaskLane`). It correctly groups transient implementation statuses like `coding_completed` and `QA_REVIEW_FAILED` into the "in_progress" lane for consistent Kanban rendering. -- **Task Card Affordances:** Task cards visually represent task states cleanly without expensive 3D transformations. Dependency indicators provide distinct visual styles and full screen-reader readouts for missing, blocked (`QA_REVIEW_FAILED`), running (`in_progress`, `coding_completed`), and completed dependencies. Quick actions reveal visually on hover or keyboard focus while remaining keyboard accessible. Drag actions utilize explicit `cursor-grab` interactions, omit pointer-only helper chips from visible metadata, preserve screen-reader guidance, and gracefully degrade in reduced motion or disabled states. +- **Task Card Affordances:** Task cards visually represent task states cleanly without expensive 3D transformations. Dependency indicators pair compact task identifiers with normalized status copy and retain complete titles, raw state, and blocking meaning for assistive technology. A persistent task-labelled **Actions** trigger opens a viewport-contained grouped menu; unavailable actions show their reasons in place, enabled items support arrow/Home/End navigation, and dismissal restores trigger focus. Drag actions use explicit `cursor-grab` interactions, omit pointer-only helper chips from visible metadata, preserve screen-reader guidance, and become static and unavailable in reduced motion or optimistic-saving states. - **Form Validation & Submission**: When a form submission fails due to validation errors, focus should be automatically shifted to the first invalid field to assist users (especially screen readers). When submission fails due to network or logic errors, a retry action should be presented via `ActionFeedbackRegion`. Also, ensure duplicate form submissions are prevented by verifying the `isSubmitting` state before processing the submit event. Dependencies should explicitly indicate if they cannot be selected due to cycle-prevention logic, rather than silently filtering them out. diff --git a/docs/dashboard/design-system-tasks.md b/docs/dashboard/design-system-tasks.md index 06fd726bc5..51e86c9ddb 100644 --- a/docs/dashboard/design-system-tasks.md +++ b/docs/dashboard/design-system-tasks.md @@ -6,13 +6,21 @@ Task board implementation must also follow the pure dashboard view-model and ren The Tasks page and Kanban board should feel like a 'Refined Production Board'. It prioritizes clear state scannability, exact layout, and reduced visual noise. +### Operational hierarchy + +The page follows one stable hierarchy: project/sprint context and the primary **New Task** command in the intro; the named **Task board controls**; the named **Task board overview**; update feedback; and the named Kanban lanes. Filtered totals and selected-sprint completion live in the overview rather than competing with the primary command. The overview uses compact monospace metrics for filtered total, running, completed, and critical work; when a sprint is selected, its name, date, completion percentage, completed-task progress semantics, and queued/running/completed distribution share the same surface. Long sprint names wrap inside `min-w-0` content instead of widening the workspace. + +Sprint scope, status, priority, and visible-card count form one named **Task board controls** rail. The rail is a one-column stack on phones, a two-column grid on intermediate widths, and a four-part row when space permits. Each control keeps a minimum 44px target, visible signal-colored focus treatment, and an independently bounded `min-w-0` container. Filter strips may scroll inside their own control, while the rail remains `max-w-full` and neither the sprint trigger nor listbox uses viewport-width sizing. + ## 1. Board & Lanes * **Containers:** Use precise framing with subtle inner shadows and distinct but calm borders (e.g., `border-black/[0.06] dark:border-white/[0.06]`). * **Headers:** Lane headers should establish hierarchy using `font-display` for main titles and monospace text (`font-mono`) for metadata (like counts or tags). +* **Accessible Names:** Each lane heading includes its task count in screen-reader text, producing names such as `Queued lane, 3 tasks`. The visible count chip is decorative to avoid announcing the same number twice, while the lane's polite description reports the current filtered count. * **Counts:** Use restrained chips for counts (e.g., `bg-black/[0.03] dark:bg-white/[0.03]`) rather than bold solid colors, unless indicating a critical bottleneck. * **Empty States:** Empty lanes should be visually quiet, with dotted borders or subtle backgrounds to indicate they are active but empty, avoiding heavy text. * **Drop Behavior:** Pointer drops are status transitions only. Dropping a card onto another visible lane resolves to that lane's default persisted status (`pending`, `in_progress`, or `completed`), while same-lane drops are no-ops because task ordering is not persisted by the task API. * **Filtered Feedback:** Status filters, priority filters, sprint scope, and visible-card windows must expose `aria-live="polite"` summaries when their selected state changes. Empty lanes should announce that the lane is empty after current filters, in the selected sprint, or in the project, and should keep a stable minimum height so filter transitions do not collapse the board. +* **Responsive Framing:** Lane sections own their header and drop surface inside one calm frame. Boards render one column by default, two at `lg`, and three at `xl`; every grid, lane, and card-list boundary uses `min-w-0`. Drop surfaces keep a stable minimum height during loading, filtering, and empty results, and skeletons occupy the same framed lane body as settled cards. * **Motion Contracts:** Board lists use the tokenized interaction contracts consistently: `controlFeedback` for local chip and control feedback, `selectionMovement` for active filter and sprint selection movement, `listReveal` for selector popovers and newly revealed lists, and `listReorder` for card/lane reorder transitions. Reduced-motion mode resolves those token durations to zero and must retain the same static state text, borders, and focus rings. * **Route Scope Ownership:** Project and sprint selection must stay synchronized with TanStack Router search state. A project change on Tasks replaces `projectId` and clears the previous sprint; a sprint change replaces `sprintId` for the active project. Do not mutate `window.history` directly for these controls, because stale route search can override a later selector choice. @@ -22,9 +30,10 @@ The Tasks page and Kanban board should feel like a 'Refined Production Board'. I * **Hover State:** On hover, elevate the card slightly (`scale-102` or `translate-y-[-2px]`), increase shadow (`shadow-[0_4px_24px_rgba(0,0,0,0.12)]`), and potentially add a very soft background tint (e.g., `bg-signal-500/[0.02]`). * **Typography:** Task titles (`h4`) should be highly legible, slightly condensed (`tracking-tight`), and robust (`font-bold`). To prevent unbroken strings from causing horizontal overflow in narrow components (e.g., task cards), apply `break-words` and `whitespace-normal` to multiline text elements like titles. * **Truncation:** Ensure long text in tags (like source, agent name, dependency titles) properly truncates without breaking layout (`truncate max-w-[...]`). When placing dense data components (like feeds, IDs, or stat grids) inside responsive grid columns, apply `min-w-0` to the internal flex/grid sub-layout containers. Without it, nested containers default to `min-width: auto` and will horizontally expand the parent grid. -* **Quick Actions:** Task card quick actions stay mounted and keyboard reachable at all times. Fine-pointer hover may increase contrast or emphasis, but required actions must not depend on hover. Each action uses a stable icon slot, stable text slot, fixed minimum hit target, target-specific accessible name, and a disabled reason exposed through `aria-describedby` when unavailable. -* **Unavailable Actions:** When rerun, PR, live runtime, preview, edit, or delete are unavailable, keep the control in place and expose both a per-control reason and a compact visible summary in the action rail. The visible summary should state the unavailable/pending group without pushing card content around; the per-control accessible description should include the exact task target and reason. -* **Pending State:** Optimistic saves and dispatch-like pending states must suppress duplicate activation. Use inert buttons rather than actionable links while a task action is pending, expose `aria-busy`, keep the button in the same location, and announce the reason without resizing the card. +* **Task Actions:** Every card keeps one persistent, target-labelled **Actions** trigger in its metadata footer. It opens the shared portal-backed dropdown with **Execution & navigation** (Rerun, Preview, eligible PR, and Live), **Task management** (Edit), and **Danger zone** (Delete) groups. The trigger never depends on hover and retains a coarse-pointer hit target, visible focus, pressed feedback, and reduced-motion-safe styling. +* **Unavailable Actions:** Actions included by the current task and project settings remain discoverable when unavailable. Each inert menu item keeps its target-specific accessible name and shows the exact disabled reason directly below its label with matching `aria-describedby` text. Rerun directs operators to Live instead of dispatching from the Tasks card; task PR is omitted only when task PR creation is disabled and no existing PR URL is available. Enabled internal and external destinations retain safe URLs and external-link protections. +* **Pending State:** Optimistic saves and dispatch-like pending states suppress duplicate activation without disabling the Actions trigger. Mutation and navigation items become inert, expose `aria-busy` and their pending reason, and remain visible in the menu so users can understand availability without resizing the card. +* **Menu Keyboard And Focus:** Click, Enter, Space, Arrow Down, and Arrow Up open the menu. Opening focuses the first enabled action, except Arrow Up starts at the last; arrow keys wrap across enabled items, Home/End jump to the first/last enabled item, and Enter/Space activate the focused item. Escape and outside dismissal close the menu and restore focus to its task-labelled trigger. Delete closes the menu and opens the shared destructive confirmation; cancelling that confirmation restores focus to the same trigger. ## 3. Status & Execution Metadata * **Unified Status System:** All task-related metadata—priority, dependencies, and execution state—must share a consistent visual language. @@ -34,7 +43,7 @@ The Tasks page and Kanban board should feel like a 'Refined Production Board'. I * Blocked/Pending: Muted slate (`bg-slate-400/[0.08] text-slate-500`). * Unknown dependency records must render a visible `Unknown` label with dashed neutral styling, not only a missing color state. * QA-failed dependencies must use error semantics and visible `QA failed` copy, while pending dependencies use a visible `Blocked` label and warning semantics. - * Each dependency chip should include static `Blocking` or `Clear` copy in addition to the status label so reduced-motion and color-blind users can identify blocker state without relying on color, animation, or lane movement. + * The dependency summary retains the blocker count. Each dependency list row visibly contains only the task identifier and normalized current status; titles, blocker/resolved meaning, raw status, and complete state descriptions remain in the accessible name and tooltip instead of adding redundant `Blocking` or `Clear` pills. * **Execution Meta:** Use distinct but subtle icons (Cpu, User) and uniform spacing. * **Live Metadata:** Runtime duration, PR availability, QA review state, self-reflection rating state, and dependency blocker state must be visible or available as text equivalents and announced politely when they change. Default Auto executor metadata should not become prominent visible card content; keep it available only where it adds context, such as screen-reader metadata or detailed execution surfaces. * **Self-Reflection Ratings:** Use the shared `SelfReflectionRatingBadge` for compact task-run self-reflection scores near the task id, status, and priority metadata once `selfReflectionRating` is present. It renders a shrink-resistant 5-star meter with numeric copy, uses a meter label such as `Self-reflection rating N out of 5`, and opens a viewport-positioned section-rating tooltip on hover or keyboard focus. Section rows must include the section label, numeric score, star state, and note text when present so users are never asked to infer rating from color alone. Missing or malformed ratings must collapse to no DOM output: unrated cards must not render empty placeholder wrappers, disabled badges, skeleton badges, or reserved rating slots. @@ -60,10 +69,11 @@ The Tasks page and Kanban board should feel like a 'Refined Production Board'. I * **Submit Outcomes:** Pending task saves keep close/cancel/save controls disabled with an exposed reason and `aria-busy` where applicable. Save failures keep the modal open, preserve draft content and dependency selections, and expose a Retry action that resubmits the same task draft. ## 5. General Rules -* **Accessibility:** Preserve `focus-visible` styles on all interactive elements. Use `sr-only` text for screen readers where visual data is primarily conveyed via color or icons. Task cards must use `aria-live="polite"` regions to announce status changes (optimistic, pending, QA review, dependency blocker resolution, PR availability, and live runtime availability). Keyboard reordering is not currently supported; if draggable elements are pointer-only or disabled by reduced motion/saving state, this must be explicitly stated in `.sr-only` text and preserved in the card accessible context instead of adding low-value visible metadata chips. Pointer drag disabled states must also be reflected through stable card attributes, borders, and focus styles so reduced-motion users are not dependent on animation. Action controls must remain visible and reachable without hover; hover and `focus-within` may only enhance an already available control group. For row-level action controls, always include the specific item's name or identifier in the `aria-label` and `title` attributes. +* **Accessibility:** Preserve `focus-visible` styles on all interactive elements. Use `sr-only` text for screen readers where visual data is primarily conveyed via color or icons. Task cards must use `aria-live="polite"` regions to announce status changes (optimistic, pending, QA review, dependency blocker resolution, PR availability, and live runtime availability). Keyboard reordering is not currently supported; if draggable elements are pointer-only or disabled by reduced motion/saving state, this must be explicitly stated in `.sr-only` text and preserved in the card accessible context instead of adding low-value visible metadata chips. Pointer drag disabled states must also be reflected through stable card attributes, borders, and focus styles so reduced-motion users are not dependent on animation. The task action trigger must remain visible and reachable without hover, stop pointer/menu interactions from initiating card drag, and restore focus after Escape, outside dismissal, or cancelled deletion. For row-level action controls, always include the specific item's name or identifier in the `aria-label` and `title` attributes. * **Pending Actions:** Task card and active-stream row controls must suppress duplicate activation while pending or disabled. Expose the target task and reason through native `disabled` where possible, `aria-busy`, `aria-describedby`, visible or screen-reader-readable reason text, and stable hit-target dimensions. * **Layout Constraints:** When configuring responsive grids for dense content like Kanban boards, default to a single-column layout on mobile and delay switching to multiple columns until larger viewports (e.g., `lg:` or `xl:` breakpoints) to ensure individual columns remain wide enough to be readable. -* **Motion:** Respect `isReducedMotion` or `prefers-reduced-motion` for hover elevations and transitions. Task cards use `controlFeedback` for local actions/status affordances and `listReorder` for card movement; reduced motion must retain static status badges, QA/dependency copy, edit/delete actions, PR/live metadata, and screen-reader drag-disabled guidance. +* **Motion:** Respect `isReducedMotion` or `prefers-reduced-motion` for hover elevations and transitions. Task cards use `controlFeedback` for local actions/status affordances and `listReorder` for card movement; reduced motion resolves board, card, and menu movement to a static presentation and disables pointer drag while retaining status badges, lane counts, QA/dependency copy, menu actions and reasons, PR/live metadata, focus rings, and screen-reader drag-disabled guidance. * **Responsiveness:** Use responsive wrapping (`flex-wrap`) on control bars and footers to ensure labels, metadata, controls, and PR links do not overlap or break layout on narrow viewports. Ensure dropdowns and text elements use `min-w-0` and `truncate` or `break-words` safely so they don't blow out the viewport or board layout. Kanban columns should collapse to a single column on phones and only switch to two columns on larger viewports when readable (e.g. `lg:grid-cols-2`). * **Architecture (View Models):** Maintain a clear view-model boundary for task board rendering (e.g., using `buildTaskBoardViewModel`). Ensure filtering, enrichment, column counts, and card view-model construction are extracted into pure helpers rather than recalculating them piecemeal inside `TasksPage` components. * **Architecture (Controller Hook):** `dashboard/src/v2/hooks/use-task-board-controller.ts` owns task-board orchestration for `TasksPage`: project/sprint/task data hooks, route sprint query synchronization (`sprintId` and legacy `sprint`), effective settings, agent presets, filters, list window state, optimistic create/update/drop state, refreshes, and rollback cleanup. `TasksPage` should consume the returned typed view model and callbacks, keeping only page layout, DOM refs, and GSAP effects local. +* **Acceptance Coverage:** Keep the board workspace and card menu covered together in the standard dashboard suite, including lane names/counts, filter continuity, compact dependency copy, loading/error/empty states, optimistic disabled reasons, keyboard opening, Escape/cancel focus restoration, and reduced motion. Compiled-runtime browser coverage must use isolated project/sprint/task fixtures and assert desktop/mobile lane layout, document overflow containment, viewport-contained menus, single-delivery mutations, and realtime rerenders without provider dispatch. diff --git a/docs/dashboard/interaction-patterns.md b/docs/dashboard/interaction-patterns.md index c92487a0b4..a4b62addfd 100644 --- a/docs/dashboard/interaction-patterns.md +++ b/docs/dashboard/interaction-patterns.md @@ -159,7 +159,7 @@ DropdownMenus and Popovers are expected to be fully keyboard accessible: - Route changes triggered by shell links, task links, Browser controls, or sprint/task selectors must leave the destination with a named page landmark. If focus is programmatically moved, use `preventScroll` where possible to avoid jumping fixed shell chrome. - Keyboard-only users must be able to operate Browser chrome, session rail actions, settings forms, task/sprint selectors, stats filters, command menus, and compact mobile controls without hover-only disclosure. - Task cards and active stream rows keep status, dependency blockers, QA review state, PR/live duration metadata, drag limitations, and action availability available without relying on pointer hover. Pointer drag remains pointer-only; its visual helper chip is no longer card metadata, but screen-reader drag guidance remains available. Reduced-motion users receive static drag-disabled messaging instead of keyboard drag-and-drop. -- Kanban task cards keep quick actions such as Edit, Delete, Rerun, Preview, PR, and live runtime in the bottom footer instead of overlaying task content. Fine-pointer layouts reserve the footer tray but visually hide it with opacity and pointer-event suppression until hover, card focus, focus-visible, or focus-within; touch/coarse-pointer layouts keep the mounted actions visible because hover is unavailable. Those actions stay keyboard reachable with fixed hit targets and task-specific accessible names. Dependency chips distinguish blocked, resolved, in-progress, QA-failed, and unknown dependencies inline; task cards expose `PR pending` only when task PR creation is enabled by effective project git settings, while real PR-ready links remain visible whenever a historical or runtime-enriched PR URL exists. Live runtime, QA review, optimistic saving, focus, pressed, dragging, and reduced-motion states remain available through static text, borders, badges, and accessible labels. +- Kanban task cards keep one persistent, task-labelled Actions trigger in the metadata footer instead of an expanded action rail. The shared portal-backed menu groups execution/navigation actions, Edit, and destructive Delete; it supports standard trigger keys, looping arrow traversal, Home/End, Escape, outside dismissal, trigger focus restoration, and pointer-event isolation from card drag. Unavailable and optimistic actions remain visible as inert menu items with exact row-level reasons and `aria-describedby`, while safe internal/external links keep their destination and protection attributes. Dependency summaries retain blocker counts, and each list row visibly shows only the task identifier plus normalized resolved, blocked, in-progress, ready-for-QA, QA-failed, or unknown status; full titles and blocker context remain accessible. Task cards expose `PR pending` only when task PR creation is enabled by effective project git settings, while real PR-ready links remain visible whenever a historical or runtime-enriched PR URL exists. Live runtime, QA review, optimistic saving, focus, pressed, dragging, and reduced-motion states remain available through static text, borders, badges, and accessible labels. See the [Dashboard Accessibility Quality Audit](./accessibility-quality-audit.md) for verification expectations. diff --git a/docs/development/rapid-orchestration-debugging.md b/docs/development/rapid-orchestration-debugging.md index 9e9703c5cc..5e5a32feec 100644 --- a/docs/development/rapid-orchestration-debugging.md +++ b/docs/development/rapid-orchestration-debugging.md @@ -87,7 +87,7 @@ The Playwright workflow keeps lightweight legacy aggregate jobs named `Playwrigh In LOCAL git mode, recovered worker-branch evidence is treated as dependency state: downstream DAG tasks stay blocked until the parent branch has merged into the sprint feature branch or the parent is proven to have no merge work. -Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. +Local CLI git finalization is also branch-evidence state. If a `cli_git_pushed` task-run event records a `pushedBranch`, the merge gate backfills that worker branch before dependency derivation. If a restart lands after the feature ref is updated but before merge metadata is persisted, recovery proves the surviving worker branch is already an ancestor of the feature branch and restores `isMerged`, `MERGED`, and the `merged_branch` event before downstream tasks unlock. If pushed git work is recorded but no worker branch can be recovered, the task fails closed in `MERGE_BLOCKED` instead of settling as no-output work, so downstream DAG tasks cannot start against an incomplete feature branch. Sprint finalization also reads that task-run evidence; a flattened `COMPLETED` task row cannot close the sprint while pushed local CLI work is still missing a `merged_branch` or `no_merge_work` gate event. Host-execution DAG tasks export their worker output through an isolated temporary Git index. The exporter discovers modified, deleted, and untracked paths with Git's ignore rules, stages that path list into the temporary index, and emits a cached binary diff against the task base. Host worktrees use an absolute temporary index path, while Docker workspaces keep a container-relative path, so Git for Windows and container Git both write the export index in the intended workspace. This prevents ignored runtime caches from entering worker branches while ensuring parent-created files are visible before dependent tasks unlock. diff --git a/src/domain/sprint/ci/feature-pr-gate.ts b/src/domain/sprint/ci/feature-pr-gate.ts index a69a4997bf..7a97964168 100644 --- a/src/domain/sprint/ci/feature-pr-gate.ts +++ b/src/domain/sprint/ci/feature-pr-gate.ts @@ -2,7 +2,7 @@ import { evaluateMergeReadiness } from "./feature-pr/merge-readiness-policy.js"; import { deriveChecksFromCiRuns } from "../../../sprint/ci-status-utils.js"; import { runCommandStrict } from "../../../services/cli-process-runner.js"; import type { GuardrailService } from "../../../services/guardrail-service.js"; -import { createTemporaryWorktreeBranchMerger, deleteBranchLocally, findRecoverableWorkerBranch, mergeBranchLocallyInTemporaryWorktree, workerBranchHasMergeWork } from "../../../infrastructure/git/local-merge.js"; +import { createTemporaryWorktreeBranchMerger, deleteBranchLocally, findRecoverableWorkerBranch, mergeBranchLocallyInTemporaryWorktree, workerBranchHasMergeWork, workerBranchIsMergedIntoFeature } from "../../../infrastructure/git/local-merge.js"; import { buildWorkerBranchPrefix } from "../../../services/cli-workflow-utils.js"; import { matchMergedPrForTask, matchPrForTask } from "./feature-pr/pr-matcher.js"; import { attemptAutoMerge } from "./feature-pr/automerge-policy.js"; @@ -335,31 +335,46 @@ export class FeaturePrGateService { continue; } + const info = taskCiInfoMap.get(task.id)!; const hasMergeWork = await workerBranchHasMergeWork({ repoPath: context.repoPath, featureBranch: context.featureBranch, workerBranch, }); if (!hasMergeWork) { + const recoveredCompletedMerge = context.githubMode === "LOCAL" + && info.cliGitPushed + && !info.cliGitNoChanges + && await workerBranchIsMergedIntoFeature({ + repoPath: context.repoPath, + featureBranch: context.featureBranch, + workerBranch, + }); task.status = "COMPLETED"; - task.merge_indicator = undefined; + task.is_merged = recoveredCompletedMerge; + task.merge_indicator = recoveredCompletedMerge ? "MERGED" : undefined; task.worker_branch = undefined; if (context.executionRepository && context.sprintRunId && task.record_id) { const taskRun = context.executionRepository.getLatestTaskRun(task.record_id, context.sprintRunId); if (taskRun?.id) { context.executionRepository.updateTaskRun(taskRun.id, { workerBranch: null }); context.executionRepository.appendTaskRunEvent(taskRun.id, "ci_gate_status", "system", { - state: "no_merge_work", + state: recoveredCompletedMerge ? "merged_branch" : "no_merge_work", taskId: task.id, featureBranch: context.featureBranch, workerBranch, + ...(recoveredCompletedMerge ? { githubMode: context.githubMode } : {}), }, { - sourceEventKey: `ci-gate:no_merge_work:none:${workerBranch}`, + sourceEventKey: recoveredCompletedMerge + ? `ci-gate:merged_branch:${context.featureBranch}:${workerBranch}` + : `ci-gate:no_merge_work:none:${workerBranch}`, }); } } await context.persistMergedTask(task); - reportText += `- ✅ **No merge work:** Task \`${task.id}\` completed without a PR because no worker branch with unmerged commits exists.\n`; + reportText += recoveredCompletedMerge + ? `- ✅ **Recovered local merge:** Task \`${task.id}\` was already merged into \`${context.featureBranch}\`; merge metadata was restored.\n` + : `- ✅ **No merge work:** Task \`${task.id}\` completed without a PR because no worker branch with unmerged commits exists.\n`; continue; } diff --git a/src/infrastructure/git/local-merge.ts b/src/infrastructure/git/local-merge.ts index 5856683fc7..f4244351ae 100644 --- a/src/infrastructure/git/local-merge.ts +++ b/src/infrastructure/git/local-merge.ts @@ -524,6 +524,52 @@ export async function workerBranchHasMergeWork(args: { return false; } +/** + * Returns true when the recorded worker branch still exists and every commit on + * it is already reachable from the feature branch. This lets restart recovery + * distinguish an interrupted post-merge persistence step from a missing or + * genuinely no-output worker branch. + */ +export async function workerBranchIsMergedIntoFeature(args: { + repoPath: string; + featureBranch: string; + workerBranch: string; + runner?: LocalMergeRunner; +}): Promise { + const runner = args.runner ?? defaultRunner; + const branch = args.workerBranch.trim(); + if (!branch) return false; + + const sourceRefs = [ + `refs/heads/${branch}`, + `refs/remotes/origin/${branch}`, + ]; + const baseRefs = [ + `refs/remotes/origin/${args.featureBranch}`, + `refs/heads/${args.featureBranch}`, + ]; + + for (const sourceRef of sourceRefs) { + if (!(await gitRefExists(args.repoPath, sourceRef, runner))) continue; + const sourceCommit = await gitResolveCommit(args.repoPath, sourceRef, runner); + if (!sourceCommit) continue; + + for (const baseRef of baseRefs) { + if (!(await gitRefExists(args.repoPath, baseRef, runner))) continue; + const baseCommit = await gitResolveCommit(args.repoPath, baseRef, runner); + if (!baseCommit) continue; + try { + const result = await runner("git", ["rev-list", "--count", `${baseCommit}..${sourceCommit}`], args.repoPath); + if (Number.parseInt(result.stdout.trim(), 10) === 0) return true; + } catch { + // Try the next local/remote ref pair before treating the merge as unproven. + } + } + } + + return false; +} + /** * Deletes a local branch after its work has been merged. Never deletes the branch that is currently * checked out (git refuses anyway) and swallows errors — branch cleanup is best-effort and must diff --git a/tests/backend/domain/sprint/ci/feature-pr-gate.test.ts b/tests/backend/domain/sprint/ci/feature-pr-gate.test.ts index 77c402f205..063407a52d 100644 --- a/tests/backend/domain/sprint/ci/feature-pr-gate.test.ts +++ b/tests/backend/domain/sprint/ci/feature-pr-gate.test.ts @@ -857,6 +857,74 @@ jobs: expect(result.reportText).toContain("Merged locally"); }); + it("restores merged metadata when restart recovery finds the worker branch already integrated", async () => { + context.githubMode = "LOCAL"; + context.gitStatus.openPullRequests = []; + context.gitStatus.mergedPullRequests = []; + subtasks[0].status = "CODING_COMPLETED"; + subtasks[0].worker_branch = "feat/T1"; + subtasks[0].pr_url = undefined; + subtasks[0].is_merged = false; + context.executionRepository = { + getLatestTaskRun: vi.fn().mockReturnValue({ + id: "run-1", + provider: "mockup-cli", + mode: "docker_cli", + state: "COMPLETED", + workerBranch: "feat/T1", + }), + listTaskRunEvents: vi.fn().mockReturnValue([ + { eventType: "cli_provider_completed" }, + { eventType: "cli_git_pushed", payload: { pushedBranch: "feat/T1" } }, + ]), + updateTaskRun: vi.fn(), + appendTaskRunEvent: vi.fn(), + } as any; + vi.mocked(runCommandStrict).mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "rev-parse") { + const ref = args[2] || ""; + return Promise.resolve({ + stdout: ref.includes("feat/T1") + ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + : "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + stderr: "", + } as any); + } + if (args[0] === "rev-list") { + return Promise.resolve({ stdout: "0", stderr: "" } as any); + } + return Promise.resolve({ stdout: "", stderr: "" } as any); + }); + + const result = await service.evaluateCiGate(subtasks, context); + + expect(result.subtasks[0]).toMatchObject({ + status: "COMPLETED", + is_merged: true, + merge_indicator: "MERGED", + worker_branch: undefined, + }); + expect(context.persistMergedTask).toHaveBeenCalledWith(expect.objectContaining({ + id: "T1", + is_merged: true, + merge_indicator: "MERGED", + })); + expect(context.executionRepository.appendTaskRunEvent).toHaveBeenCalledWith( + "run-1", + "ci_gate_status", + "system", + expect.objectContaining({ + state: "merged_branch", + workerBranch: "feat/T1", + githubMode: "LOCAL", + }), + expect.objectContaining({ + sourceEventKey: "ci-gate:merged_branch:feature/sprint1:feat/T1", + }), + ); + expect(result.reportText).toContain("Recovered local merge"); + }); + it("reuses one temporary worktree when multiple LOCAL worker branches are ready", async () => { context.githubMode = "LOCAL"; subtasks = [ diff --git a/tests/backend/infrastructure/git/local-merge.test.ts b/tests/backend/infrastructure/git/local-merge.test.ts index babed7ec21..0b3b04a3f6 100644 --- a/tests/backend/infrastructure/git/local-merge.test.ts +++ b/tests/backend/infrastructure/git/local-merge.test.ts @@ -13,6 +13,7 @@ import { mergeBranchLocallyInTemporaryWorktree, findRecoverableWorkerBranch, workerBranchHasMergeWork, + workerBranchIsMergedIntoFeature, deleteBranchLocally, } from "../../../../src/infrastructure/git/local-merge.js"; @@ -907,3 +908,50 @@ describe("workerBranchHasMergeWork", () => { expect(revListRanges).toEqual(["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]); }); }); + +describe("workerBranchIsMergedIntoFeature", () => { + let repo: string; + + beforeEach(async () => { + repo = await mkdtemp(path.join(tmpdir(), "worker-merged-evidence-")); + await git(repo, "init", "-b", "main"); + await git(repo, "config", "user.email", "test@example.com"); + await git(repo, "config", "user.name", "Test"); + await commitFile(repo, "base.txt", "base\n", "Initial commit"); + await git(repo, "branch", "feature"); + }); + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }); + }); + + it("recognizes a worker branch already integrated into the feature branch", async () => { + await git(repo, "checkout", "-b", "task/merged", "feature"); + await commitFile(repo, "work.txt", "work\n", "feat: work"); + await git(repo, "checkout", "feature"); + await git(repo, "merge", "--no-ff", "task/merged", "-m", "Merge worker"); + + await expect(workerBranchIsMergedIntoFeature({ + repoPath: repo, + featureBranch: "feature", + workerBranch: "task/merged", + })).resolves.toBe(true); + }); + + it("does not treat unmerged or missing worker branches as merged", async () => { + await git(repo, "checkout", "-b", "task/unmerged", "feature"); + await commitFile(repo, "work.txt", "work\n", "feat: work"); + await git(repo, "checkout", "main"); + + await expect(workerBranchIsMergedIntoFeature({ + repoPath: repo, + featureBranch: "feature", + workerBranch: "task/unmerged", + })).resolves.toBe(false); + await expect(workerBranchIsMergedIntoFeature({ + repoPath: repo, + featureBranch: "feature", + workerBranch: "task/missing", + })).resolves.toBe(false); + }); +}); diff --git a/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx b/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx index f1ba9784eb..ad2bd4f625 100644 --- a/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx +++ b/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx @@ -594,7 +594,7 @@ describe("dashboard accessibility quality regressions", () => { expect(screen.getByRole("region", { name: provider.name })).toBeInTheDocument(); expect(screen.getByRole("radiogroup", { name: `${provider.name} authentication mode` })).toBeInTheDocument(); expect(screen.getByRole("button", { name: `Remove ${provider.name}` })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Delete task TASK-LONG:/ })).toHaveAccessibleName(/Very long task title/); + expect(screen.getByRole("button", { name: /Open task actions for task TASK-LONG:/ })).toHaveAccessibleName(/Very long task title/); expect(container.querySelector(".kanban-card h4")).toHaveClass("break-words"); expect(screen.getByRole("list", { name: "1 preview sessions" })).toHaveClass("overflow-x-auto"); expect(screen.getByRole("button", { name: `Select preview session ${previewSession.sprintName}` })).toHaveAccessibleName( @@ -644,6 +644,10 @@ describe("dashboard accessibility quality regressions", () => { const tokenStyles = readSource("dashboard/src/v2/styles/tokens.css"); expect(tokenStyles).toMatch(/:root\[data-reduced-motion="true"\]/); expect(tokenStyles).toMatch(/:root\[data-reduced-motion="REDUCE"\]/); + + const taskCardStyles = readSource("dashboard/src/v2/components/tasks/kanban-task-card.css"); + expect(taskCardStyles).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.kanban-card__action-trigger[\s\S]*transition: none;[\s\S]*transform: none;/); + expect(taskCardStyles).toMatch(/\.kanban-card-reduced-motion \.kanban-card__action-trigger,[\s\S]*transition: none;[\s\S]*transform: none;/); }); it("guards refined interaction surfaces against hardcoded motion timing", () => { @@ -742,8 +746,13 @@ describe("dashboard accessibility quality regressions", () => { expect(settingsRail).toMatch(/Disabled/); const taskCard = readSource("dashboard/src/v2/components/tasks/KanbanTaskCard.tsx"); - expect(taskCard).toMatch(/Edit task \$\{task\.id\}/); - expect(taskCard).toMatch(/Delete task \$\{task\.id\}/); + const taskActionMenu = readSource("dashboard/src/v2/components/tasks/TaskCardActionMenu.tsx"); + expect(taskActionMenu).toMatch(/Open task actions for task \$\{task\.id\}/); + expect(taskActionMenu).toMatch(/Edit task \$\{task\.id\}/); + expect(taskActionMenu).toMatch(/Delete task \$\{task\.id\}/); + expect(taskActionMenu).toMatch(/aria-describedby=\{task\.isOptimistic \? editReasonId : undefined\}/); + expect(taskActionMenu).toMatch(/aria-describedby=\{task\.isOptimistic \? deleteReasonId : undefined\}/); expect(taskCard).not.toMatch(/group-hover:opacity-100/); + expect(taskActionMenu).not.toMatch(/group-hover:opacity-100/); }); }); diff --git a/tests/dashboard/v2/task-board-layout.test.tsx b/tests/dashboard/v2/task-board-layout.test.tsx new file mode 100644 index 0000000000..9e1d2f1985 --- /dev/null +++ b/tests/dashboard/v2/task-board-layout.test.tsx @@ -0,0 +1,221 @@ +/** @vitest-environment happy-dom */ +/** @jsx h */ +import { createRef, h } from "preact"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/preact"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskBoardFeedback } from "../../../dashboard/src/v2/TasksPage.js"; +import { TaskBoardColumns } from "../../../dashboard/src/v2/components/tasks/TaskBoardColumns.js"; +import { TaskBoardFilters } from "../../../dashboard/src/v2/components/tasks/TaskBoardFilters.js"; +import { TaskBoardOverview } from "../../../dashboard/src/v2/components/tasks/TaskBoardOverview.js"; +import { buildTaskCardViewModel } from "../../../dashboard/src/v2/lib/tasks/task-card-view-model.js"; +import { createMockTask } from "../../../dashboard/src/v2/components/tasks/__tests__/fixtures/tasks.fixture.js"; +import type { Sprint, Task, TaskStatus } from "../../../dashboard/src/v2/types.js"; + +expect.extend(matchers); + +vi.mock("gsap", () => { + const gsap = { + context: vi.fn((callback?: () => void) => { + callback?.(); + return { revert: vi.fn() }; + }), + set: vi.fn(), + to: vi.fn(), + fromTo: vi.fn(), + }; + return { default: gsap, gsap }; +}); + +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ + useReducedMotion: vi.fn(() => true), + useResolvedMotionDuration: vi.fn((duration: number | string) => typeof duration === "number" ? 0 : "0ms"), +})); + +vi.mock("../../../dashboard/src/v2/components/tasks/KanbanTaskCard.js", () => ({ + KanbanTaskCard: ({ viewModel }: { viewModel: { task: Task } }) => ( +
{viewModel.task.title}
+ ), +})); + +class MockResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +const longSprintName = "A deliberately long sprint name that must stay inside the responsive task control rail"; + +function createSprint(): Sprint { + return { + id: "sprint-layout", + projectId: "project-layout", + number: 12, + slug: "sprint-layout", + name: longSprintName, + isGeneratedName: false, + originalPrompt: null, + goal: "Validate the task workspace layout", + status: "running", + showcasePinned: false, + startDate: null, + endDate: null, + featureBranch: null, + baseCommitSha: null, + tasksCount: 4, + completion: 25, + linkedIssues: [], + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + date: "Jul 13", + }; +} + +function createTask(recordId: string, status: TaskStatus, priority: Task["priority"] = "medium"): Task { + return createMockTask({ + recordId, + id: `TASK-${recordId}`, + title: `${status} ${recordId}`, + sprintId: "sprint-layout", + sprint: "SPR-12", + status, + priority, + }); +} + +const noop = vi.fn(); + +describe("task board command surface layout", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + cleanup(); + }); + + it("presents task totals beneath an accessible sprint progress hierarchy", () => { + const tasks = [ + createTask("queued", "pending"), + createTask("critical", "pending", "critical"), + createTask("running", "in_progress"), + createTask("done", "completed"), + ]; + + render( + , + ); + + const overview = screen.getByRole("region", { name: "Task board overview" }); + expect(within(overview).getByText(longSprintName)).toBeInTheDocument(); + const progress = within(overview).getByRole("progressbar", { name: `Sprint progress for ${longSprintName}` }); + expect(progress).toHaveAttribute("aria-valuenow", "25"); + expect(progress).toHaveAttribute("aria-valuetext", "1 of 4 tasks complete"); + expect(overview).toHaveTextContent("1 completed"); + expect(overview).toHaveTextContent("1 running"); + expect(overview).toHaveTextContent("2 queued"); + expect(overview).toHaveTextContent("Filtered total"); + }); + + it("wraps sprint, status, priority, and visible-card controls without viewport-width classes", () => { + render( + , + ); + + const controls = screen.getByRole("region", { name: "Task board controls" }); + expect(controls).toHaveAttribute("data-task-control-rail", "responsive"); + expect(controls).toHaveClass("min-w-0", "max-w-full"); + expect(controls.firstElementChild?.nextElementSibling?.nextElementSibling).toHaveClass("grid-cols-1", "md:grid-cols-2"); + expect(within(controls).getByRole("tablist", { name: "Task status filter" })).toBeInTheDocument(); + expect(within(controls).getByRole("tablist", { name: "Task priority filter" })).toBeInTheDocument(); + expect(within(controls).getByRole("button", { name: "Select number of task cards per lane" })).toBeInTheDocument(); + + const sprintTrigger = within(controls).getByRole("button", { name: new RegExp(`Task sprint scope: SPR-12: ${longSprintName}`) }); + expect(sprintTrigger).toHaveClass("w-full", "min-w-0"); + expect(sprintTrigger).toHaveStyle({ transitionDuration: "0ms" }); + fireEvent.click(sprintTrigger); + + const listbox = within(controls).getByRole("listbox", { name: "Task sprint scope" }); + expect(listbox).toHaveClass("w-full", "min-w-0", "motion-reduce:transition-none"); + expect(`${controls.className} ${listbox.className}`).not.toMatch(/100vw|w-screen/); + const runningDot = listbox.querySelector('[data-sprint-status-dot="running"]'); + expect(runningDot).toHaveClass("motion-reduce:animate-none", "motion-reduce:ring-2"); + }); + + it("keeps one, two, and three-lane framing named, counted, and stable through empty and loading states", () => { + const queued = createTask("queued", "pending"); + const runningA = createTask("running-a", "in_progress"); + const runningB = createTask("running-b", "coding_completed"); + const tasks = [queued, runningA, runningB]; + const taskViewModels = new Map(tasks.map((task) => [task.recordId, buildTaskCardViewModel(task, new Map(tasks.map((item) => [item.recordId, item])))])); + const columns = [ + { status: "pending" as const, count: 1, tasks: [queued] }, + { status: "in_progress" as const, count: 2, tasks: [runningA, runningB] }, + { status: "completed" as const, count: 0, tasks: [] }, + ]; + const baseProps = { + boardRef: createRef(), + columns, + taskViewModels, + allTasks: tasks, + agentPresetsMap: new Map(), + loading: false, + showSkeletons: false, + filterTransitionPending: false, + statusFilter: "all" as const, + priorityFilter: "all" as const, + taskScopeSprintId: "sprint-layout", + reducedMotion: true, + draggedTaskId: null, + dropTargetContext: null, + listTransitionStyle: { transitionDuration: "0ms", transitionTimingFunction: "linear" }, + onDragOver: noop, + onDrop: noop, + onDragStart: noop, + onDragEnd: noop, + onEditTask: noop, + onDeleteTask: noop, + }; + + const view = render(); + const board = view.container.firstElementChild; + expect(board).toHaveAttribute("data-board-column-count", "3"); + expect(board).toHaveClass("grid-cols-1", "lg:grid-cols-2", "xl:grid-cols-3", "min-w-0"); + expect(screen.getByRole("region", { name: "Queued lane, 1 task" })).toHaveAccessibleDescription(/contains 1 task after current filters/i); + expect(screen.getByRole("region", { name: "In Progress lane, 2 tasks" })).toHaveAccessibleDescription(/contains 2 tasks after current filters/i); + const emptyLane = screen.getByRole("region", { name: "Completed lane, 0 tasks" }); + expect(emptyLane).toHaveAttribute("data-reduced-motion", "true"); + expect(within(emptyLane).getByRole("status")).toHaveTextContent("This sprint has no work in this lane."); + + view.rerender(); + expect(screen.getByText("Loading queued tasks.")).toBeInTheDocument(); + expect(screen.getByText("Loading in progress tasks.")).toBeInTheDocument(); + expect(screen.getByText("Loading completed tasks.")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Queued lane, 1 task" })).toHaveAttribute("aria-busy", "true"); + }); + + it("exposes deliberate error and filter-transition feedback", () => { + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("Task board update failed"); + expect(screen.getByRole("alert")).toHaveTextContent("The task service did not respond."); + expect(screen.getByRole("status")).toHaveTextContent("Current cards remain visible until results settle."); + }); +}); diff --git a/tests/dashboard/v2/task-row-qa-review.test.tsx b/tests/dashboard/v2/task-row-qa-review.test.tsx index 31322ca934..d70b64a3a8 100644 --- a/tests/dashboard/v2/task-row-qa-review.test.tsx +++ b/tests/dashboard/v2/task-row-qa-review.test.tsx @@ -1,8 +1,7 @@ /** * @vitest-environment jsdom */ -import { cleanup, render, screen } from "@testing-library/preact"; -import { fireEvent } from "@testing-library/preact"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; expect.extend(matchers); import { describe, expect, it, vi, beforeEach } from "vitest"; @@ -104,7 +103,9 @@ describe("TaskRow QA review indicator", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /Delete task T1: Reviewed task/i })); + fireEvent.click(screen.getByRole("button", { name: /Open task actions for task T1: Reviewed task/i })); + const menu = await screen.findByRole("menu", { name: /Actions for task T1: Reviewed task/i }); + fireEvent.click(within(menu).getByRole("menuitem", { name: /Delete task T1: Reviewed task/i })); expect(await screen.findByRole("dialog", { name: "Delete Task" })).toBeInTheDocument(); expect(screen.getByText(/Delete "Reviewed task"\? This removes the task card and cannot be undone/i)).toBeInTheDocument(); @@ -132,7 +133,7 @@ describe("TaskRow QA review indicator", () => { expect(screen.getByLabelText(/^Task T1: Reviewed task/i)).toHaveAttribute("draggable", "false"); }); - it("keeps task-card action names target-specific and exposes disabled reasons separately", () => { + it("keeps task-card action names target-specific and exposes disabled reasons separately", async () => { const task = makeTask(); const viewModel: TaskCardViewModel = { task, @@ -174,13 +175,16 @@ describe("TaskRow QA review indicator", () => { render(); - expect(screen.getByRole("button", { name: "Rerun task T1: Reviewed task" })).toHaveAccessibleDescription("Open Live to rerun task T1."); - expect(screen.getByRole("link", { name: "Open sprint preview for task T1: Reviewed task" })).toHaveAttribute("href", "/browser?sprintId=sprint-1"); - expect(screen.getByRole("button", { name: "Open pull request for task T1: Reviewed task" })).toHaveAccessibleDescription("No pull request is available for task T1 yet."); - expect(screen.getByRole("button", { name: "Open live runtime for task T1: Reviewed task" })).toHaveAccessibleDescription("Live runtime has not started for task T1."); + fireEvent.click(screen.getByRole("button", { name: /Open task actions for task T1: Reviewed task/i })); + const menu = await screen.findByRole("menu", { name: /Actions for task T1: Reviewed task/i }); + expect(within(menu).getByRole("menuitem", { name: "Rerun task T1: Reviewed task" })).toHaveAccessibleDescription("Open Live to rerun task T1."); + expect(within(menu).getByRole("menuitem", { name: "Open sprint preview for task T1: Reviewed task" })).toHaveAttribute("href", "/browser?sprintId=sprint-1"); + expect(within(menu).getByRole("menuitem", { name: "Open sprint preview for task T1: Reviewed task" })).toHaveAttribute("title", "Open the sprint preview workspace. Task T1."); + expect(within(menu).getByRole("menuitem", { name: "Open pull request for task T1: Reviewed task" })).toHaveAccessibleDescription("No pull request is available for task T1 yet."); + expect(within(menu).getByRole("menuitem", { name: "Open live runtime for task T1: Reviewed task" })).toHaveAccessibleDescription("Live runtime has not started for task T1."); }); - it("exposes optimistic saving state and disabled edit/delete reasons without changing action names", () => { + it("exposes optimistic saving state and disabled edit/delete reasons without changing action names", async () => { const task = makeTask(); const viewModel: TaskCardViewModel = { task: { ...task, isOptimistic: true }, @@ -197,7 +201,11 @@ describe("TaskRow QA review indicator", () => { expect(card).toHaveAttribute("aria-busy", "true"); expect(card).toHaveAttribute("draggable", "false"); expect(screen.getByText("Saving task changes")).toBeVisible(); - expect(screen.getByRole("button", { name: "Edit task T1: Reviewed task" })).toHaveAccessibleDescription("Saving task T1; edit is temporarily unavailable."); - expect(screen.getByRole("button", { name: "Delete task T1: Reviewed task" })).toHaveAccessibleDescription("Saving task T1; delete is temporarily unavailable."); + const trigger = screen.getByRole("button", { name: /Open task actions for task T1: Reviewed task/i }); + expect(trigger).toHaveAttribute("aria-busy", "true"); + fireEvent.click(trigger); + const menu = await screen.findByRole("menu", { name: /Actions for task T1: Reviewed task/i }); + expect(within(menu).getByRole("menuitem", { name: "Edit task T1: Reviewed task" })).toHaveAccessibleDescription("Saving task T1; edit is temporarily unavailable."); + expect(within(menu).getByRole("menuitem", { name: "Delete task T1: Reviewed task" })).toHaveAccessibleDescription("Saving task T1; delete is temporarily unavailable."); }); }); diff --git a/tests/dashboard/v2/tasks-page-redesign.test.tsx b/tests/dashboard/v2/tasks-page-redesign.test.tsx new file mode 100644 index 0000000000..49eedb1501 --- /dev/null +++ b/tests/dashboard/v2/tasks-page-redesign.test.tsx @@ -0,0 +1,268 @@ +/** @vitest-environment jsdom */ +/// +import { cleanup, render, screen, waitFor, within } from "@testing-library/preact"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TasksPage } from "../../../dashboard/src/v2/TasksPage.js"; +import { createMockTask } from "../../../dashboard/src/v2/components/tasks/__tests__/fixtures/tasks.fixture.js"; +import type { TaskBoardController } from "../../../dashboard/src/v2/hooks/use-task-board-controller.js"; +import { buildTaskBoardViewModel } from "../../../dashboard/src/v2/lib/tasks/task-board-view-model.js"; +import type { Source, Sprint, Task } from "../../../dashboard/src/v2/types.js"; + +expect.extend(matchers); + +const mocks = vi.hoisted(() => ({ + controller: vi.fn(), + reducedMotion: false, +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-task-board-controller.js", () => ({ + useTaskBoardController: mocks.controller, +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ + useReducedMotion: () => mocks.reducedMotion, + useResolvedMotionDuration: (duration: T): T => ( + mocks.reducedMotion + ? (typeof duration === "number" ? 0 : "0ms") as T + : duration + ), +})); + +vi.mock("gsap", async (importOriginal) => { + const actual = await importOriginal(); + const gsap = { + context: vi.fn((callback?: () => void) => { + callback?.(); + return { revert: vi.fn() }; + }), + fromTo: vi.fn().mockImplementation((_target, _from, to) => { + to?.onComplete?.(); + }), + set: vi.fn(), + to: vi.fn().mockImplementation((_target, to) => { + to?.onComplete?.(); + }), + killTweensOf: vi.fn(), + }; + return { ...actual, default: gsap, gsap }; +}); + +const project = { + id: "project-fixture", + name: "Test Project", +} as unknown as Source; + +const sprint = { + id: "sprint-fixture", + projectId: project.id, + number: 7, + name: "Redesign Fixture Sprint", + date: "Jul 14", +} as unknown as Sprint; + +function createIntegratedTasks(): Task[] { + return [ + createMockTask({ + recordId: "dependency-record", + id: "T-701", + sprintId: sprint.id, + sprint: sprint.name, + title: "Compile dependency surface", + status: "coding_completed", + priority: "high", + }), + createMockTask({ + recordId: "target-record", + id: "T-702", + sprintId: sprint.id, + sprint: sprint.name, + title: "Integrate redesigned task board", + status: "pending", + priority: "critical", + dependsOnTaskIds: ["dependency-record"], + }), + createMockTask({ + recordId: "completed-record", + id: "T-703", + sprintId: sprint.id, + sprint: sprint.name, + title: "Document acceptance states", + status: "completed", + priority: "low", + }), + ]; +} + +function createController(overrides: Partial = {}): TaskBoardController { + const tasks = overrides.tasks ?? createIntegratedTasks(); + const boardViewModel = overrides.boardViewModel ?? buildTaskBoardViewModel({ + tasks, + optimisticTasks: [], + statusFilter: overrides.statusFilter ?? "all", + priorityFilter: overrides.priorityFilter ?? "all", + listWindow: overrides.listWindow ?? 20, + taskScopeSprintId: sprint.id, + projectId: project.id, + taskDispatches: [], + attentionItems: [], + recentEvents: [], + subtasks: [], + }); + + return { + projects: [project], + selectedProject: project, + sprints: [sprint], + sprintsLoading: false, + selectedSprintId: sprint.id, + taskScopeSprintId: sprint.id, + selectedSprintModel: sprint, + sprintKeyPrefix: "TST", + isTaskScopeReady: true, + tasks, + loading: false, + error: null, + statusFilter: "all", + setStatusFilter: vi.fn(), + priorityFilter: "all", + setPriorityFilter: vi.fn(), + listWindow: 20, + setListWindow: vi.fn(), + showComposer: false, + editingTask: null, + composerRef: { current: null }, + showAddProjectModal: false, + setShowAddProjectModal: vi.fn(), + reducedMotion: mocks.reducedMotion, + showSkeletons: false, + filterTransitionPending: false, + boardCountAnnouncement: "Showing 3 tasks. Queued: 1, In Progress: 1, Completed: 1.", + boardViewModel, + draggedTaskId: null, + dropTargetContext: null, + agentPresets: [], + agentPresetsMap: new Map(), + resolvedTaskId: null, + clearResolvedTaskId: vi.fn(), + handleSprintScopeSelect: vi.fn(), + handleComposerToggle: vi.fn(), + handleComposerClose: vi.fn(), + handleTaskSubmit: vi.fn(), + handleDragStart: vi.fn(), + handleDragEnd: vi.fn(), + handleDragOver: vi.fn(), + handleDrop: vi.fn(), + handleDeleteTask: vi.fn(), + handleEditClick: vi.fn(), + handleAddProject: vi.fn(), + ...overrides, + }; +} + +describe("Tasks page redesign integration", () => { + beforeEach(() => { + mocks.reducedMotion = false; + mocks.controller.mockReset(); + mocks.controller.mockReturnValue(createController()); + vi.stubGlobal("ResizeObserver", class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => ( + window.setTimeout(() => callback(performance.now()), 0) + )); + vi.stubGlobal("cancelAnimationFrame", (handle: number) => window.clearTimeout(handle)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("integrates board semantics, dependency status copy, and keyboard menu focus", async () => { + const user = userEvent.setup(); + render(); + + const workspace = screen.getByRole("region", { name: "Task Board" }); + expect(within(workspace).getByRole("heading", { name: "Tasks" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: /Queued lane/i })).toHaveAccessibleDescription(/Queued lane contains 1 task/i); + expect(screen.getByRole("region", { name: /In Progress lane/i })).toHaveAccessibleDescription(/In Progress lane contains 1 task/i); + expect(screen.getByRole("region", { name: /Completed lane/i })).toHaveAccessibleDescription(/Completed lane contains 1 task/i); + expect(screen.getByRole("progressbar", { name: /Sprint progress for Redesign Fixture Sprint/i })).toHaveAttribute("aria-valuetext", "1 of 3 tasks complete"); + + const targetCard = screen.getByLabelText(/^Task T-702: Integrate redesigned task board/i); + const dependencyRow = within(targetCard).getByRole("listitem", { + name: /Depends on task T-701, ready for qa\. Blocking dependency\./i, + }); + expect(dependencyRow).toHaveAttribute("data-dependency-state", "blocked"); + expect(within(dependencyRow).getByText("T-701")).toHaveAttribute("aria-hidden", "true"); + expect(within(dependencyRow).getByText("Ready for QA")).toHaveAttribute("aria-hidden", "true"); + + const trigger = within(targetCard).getByRole("button", { + name: "Open task actions for task T-702: Integrate redesigned task board", + }); + trigger.focus(); + await user.keyboard("{ArrowDown}"); + + const menu = await screen.findByRole("menu", { + name: "Actions for task T-702: Integrate redesigned task board", + }); + await waitFor(() => expect(within(menu).getByRole("menuitem", { name: /Open sprint preview for task T-702/i })).toHaveFocus()); + expect(within(menu).getByRole("menuitem", { name: /Rerun task T-702/i })).toHaveAccessibleDescription("Open Live to rerun task T-702."); + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(screen.queryByRole("menu", { name: /Actions for task T-702/i })).not.toBeInTheDocument(); + }); + + it("keeps accessible loading, error, and empty states in the integrated lanes", () => { + const loadingController = createController({ loading: true, showSkeletons: true }); + mocks.controller.mockReturnValue(loadingController); + const view = render(); + + expect(screen.getByText("Loading queued tasks.")).toHaveAttribute("role", "status"); + expect(screen.getByText("Loading in progress tasks.")).toHaveAttribute("role", "status"); + expect(screen.getByText("Loading completed tasks.")).toHaveAttribute("role", "status"); + + const emptyTasks: Task[] = []; + mocks.controller.mockReturnValue(createController({ + tasks: emptyTasks, + error: "Fixture refresh failed safely.", + boardViewModel: buildTaskBoardViewModel({ + tasks: emptyTasks, + optimisticTasks: [], + statusFilter: "all", + priorityFilter: "all", + listWindow: 20, + taskScopeSprintId: sprint.id, + projectId: project.id, + taskDispatches: [], + attentionItems: [], + recentEvents: [], + subtasks: [], + }), + })); + view.rerender(); + + expect(screen.getByRole("alert")).toHaveTextContent("Fixture refresh failed safely."); + expect(screen.getByText("No queued tasks")).toBeVisible(); + expect(screen.getByText("No in progress tasks")).toBeVisible(); + expect(screen.getByText("No completed tasks")).toBeVisible(); + }); + + it("preserves status semantics while disabling drag motion in reduced-motion mode", () => { + mocks.reducedMotion = true; + mocks.controller.mockReturnValue(createController({ reducedMotion: true })); + const { container } = render(); + + const targetCard = screen.getByLabelText(/^Task T-702:/i); + expect(within(targetCard).getByText("Draggable reordering is disabled in reduced motion mode.")).toHaveClass("sr-only"); + expect(targetCard).toHaveAttribute("draggable", "false"); + expect(screen.getByRole("region", { name: /Queued lane/i })).toHaveAttribute("data-reduced-motion", "true"); + expect(container.querySelector("[data-board-column-count]")).toHaveClass("motion-reduce:transition-none"); + expect(targetCard).toHaveAccessibleName(/No pull request available yet\./i); + expect(targetCard).toHaveAccessibleDescription(/Draggable reordering is disabled in reduced motion mode\./i); + }); +}); diff --git a/tests/e2e/tasks/sprint-task-lifecycle.spec.ts b/tests/e2e/tasks/sprint-task-lifecycle.spec.ts index 6c3a21ecc1..b918b42a3a 100644 --- a/tests/e2e/tasks/sprint-task-lifecycle.spec.ts +++ b/tests/e2e/tasks/sprint-task-lifecycle.spec.ts @@ -107,10 +107,14 @@ async function holdToConfirm(page: Page, button: Locator): Promise { await page.mouse.up(); } -async function activateButtonWithKeyboard(page: Page, button: Locator): Promise { - await button.scrollIntoViewIfNeeded(); - await button.focus(); +async function openTaskActionMenu(page: Page, taskKey: string, title: string): Promise { + const trigger = page.getByRole('button', { name: `Open task actions for task ${taskKey}: ${title}` }); + await trigger.scrollIntoViewIfNeeded(); + await trigger.focus(); await page.keyboard.press('Enter'); + const menu = page.getByRole('menu', { name: `Actions for task ${taskKey}: ${title}` }); + await expect(menu).toBeVisible(); + return menu; } async function clickSprintMenuAction(page: Page, sprintName: string, action: 'Edit' | 'Delete'): Promise { @@ -237,7 +241,7 @@ test.describe('sprint and task lifecycle', () => { await page.goto(`/tasks?projectId=${encodeURIComponent(project.id)}&sprintId=${encodeURIComponent(sprint.id)}`); await expectProjectSelected(page, project.name); await expectSprintSelected(page, sprint.name); - await expect(page.getByRole('heading', { level: 1, name: 'Task Board' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Tasks' })).toBeVisible(); const newTaskButton = page.getByRole('button', { name: 'New Task' }); await expect(newTaskButton).toBeEnabled(); @@ -254,11 +258,12 @@ test.describe('sprint and task lifecycle', () => { const createdTask = await getTaskByTitle(page, project.id, taskTitle, sprint.id); taskIdsForCleanup.push(createdTask.id); - await expect(page.getByRole('button', { name: `Edit task ${createdTask.taskKey}: ${taskTitle}` })).toBeVisible(); + await expect(page.getByRole('button', { name: `Open task actions for task ${createdTask.taskKey}: ${taskTitle}` })).toBeVisible(); expect(createdTask.promptMarkdown).toContain('deterministic'); expect(createdTask.priority).toBe('medium'); - await activateButtonWithKeyboard(page, page.getByRole('button', { name: `Edit task ${createdTask.taskKey}: ${taskTitle}` })); + let taskMenu = await openTaskActionMenu(page, createdTask.taskKey, taskTitle); + await taskMenu.getByRole('menuitem', { name: `Edit task ${createdTask.taskKey}: ${taskTitle}` }).click(); await expect(page.getByText('Edit Task', { exact: true })).toBeVisible(); await page.getByPlaceholder('Fix navigation layout shift').fill(editedTaskTitle); await page.getByRole('button', { name: 'high' }).click(); @@ -268,15 +273,17 @@ test.describe('sprint and task lifecycle', () => { const tasks = await fetchTasksViaApi(request, project.id, sprint.id); return tasks.find((task) => task.id === createdTask.id); }).toMatchObject({ title: editedTaskTitle, priority: 'high' }); - await expect(page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${editedTaskTitle}` })).toBeVisible(); + const editedTaskTrigger = page.getByRole('button', { name: `Open task actions for task ${createdTask.taskKey}: ${editedTaskTitle}` }); + await expect(editedTaskTrigger).toBeVisible(); - await activateButtonWithKeyboard(page, page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${editedTaskTitle}` })); + taskMenu = await openTaskActionMenu(page, createdTask.taskKey, editedTaskTitle); + await taskMenu.getByRole('menuitem', { name: `Delete task ${createdTask.taskKey}: ${editedTaskTitle}` }).click(); const dialog = page.getByRole('dialog', { name: 'Delete Task' }); await expect(dialog).toBeVisible(); await holdToConfirm(page, dialog.getByRole('button', { name: 'Hold to Delete Task' })); await expectTaskAbsent(page, project.id, createdTask.id); - await expect(page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${editedTaskTitle}` })).toHaveCount(0); + await expect(editedTaskTrigger).toHaveCount(0); taskIdsForCleanup = taskIdsForCleanup.filter((taskId) => taskId !== createdTask.id); await deleteSprint(request, project.id, sprint.id); sprintIdsForCleanup = sprintIdsForCleanup.filter((sprintId) => sprintId !== sprint.id); diff --git a/tests/e2e/tasks/task-crud.spec.ts b/tests/e2e/tasks/task-crud.spec.ts index 83d169bb52..754ba27246 100644 --- a/tests/e2e/tasks/task-crud.spec.ts +++ b/tests/e2e/tasks/task-crud.spec.ts @@ -40,10 +40,14 @@ async function holdToConfirm(page: Page, button: Locator): Promise { await page.mouse.up(); } -async function activateButtonWithKeyboard(page: Page, button: Locator): Promise { - await button.scrollIntoViewIfNeeded(); - await button.focus(); +async function openTaskActionMenu(page: Page, taskKey: string, title: string): Promise { + const trigger = page.getByRole('button', { name: `Open task actions for task ${taskKey}: ${title}` }); + await trigger.scrollIntoViewIfNeeded(); + await trigger.focus(); await page.keyboard.press('Enter'); + const menu = page.getByRole('menu', { name: `Actions for task ${taskKey}: ${title}` }); + await expect(menu).toBeVisible(); + return menu; } async function expectTaskInProject( @@ -93,7 +97,7 @@ test.describe('task CRUD from the Tasks page', () => { const promptMarkdown = 'Verify task CRUD without dispatching AI planning or Docker workers.'; await page.goto(`/tasks?projectId=${encodeURIComponent(project.id)}&sprintId=${encodeURIComponent(sprint.id)}`); - await expect(page.getByRole('heading', { level: 1, name: 'Task Board' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Tasks' })).toBeVisible(); await page.getByRole('button', { name: 'New Task' }).click(); await expect(page.getByText('Task Composer')).toBeVisible(); @@ -112,7 +116,8 @@ test.describe('task CRUD from the Tasks page', () => { const createdTask = await createResponse.json() as TaskRecord; taskIdForCleanup = createdTask.id; - await expect(page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${title}` })).toBeVisible(); + const actionTrigger = page.getByRole('button', { name: `Open task actions for task ${createdTask.taskKey}: ${title}` }); + await expect(actionTrigger).toBeVisible(); await expect.poll(() => expectTaskInProject(request, project!.id, createdTask.id)).toMatchObject({ id: createdTask.id, sprintId: sprint.id, @@ -120,7 +125,10 @@ test.describe('task CRUD from the Tasks page', () => { promptMarkdown, }); - await activateButtonWithKeyboard(page, page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${title}` })); + const taskMenu = await openTaskActionMenu(page, createdTask.taskKey, title); + const deleteAction = taskMenu.getByRole('menuitem', { name: `Delete task ${createdTask.taskKey}: ${title}` }); + await deleteAction.focus(); + await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog', { name: 'Delete Task' }); await expect(dialog).toBeVisible(); @@ -133,7 +141,7 @@ test.describe('task CRUD from the Tasks page', () => { holdToConfirm(page, dialog.getByRole('button', { name: 'Hold to Delete Task' })), ]); - await expect(page.getByRole('button', { name: `Delete task ${createdTask.taskKey}: ${title}` })).toHaveCount(0); + await expect(actionTrigger).toHaveCount(0); await expect.poll(async () => { const tasks = await fetchTasksViaApi(request, project!.id); return tasks.some((task) => task.id === createdTask.id); diff --git a/tests/e2e/tasks/tasks-page-redesign.spec.ts b/tests/e2e/tasks/tasks-page-redesign.spec.ts new file mode 100644 index 0000000000..e8e70a77dd --- /dev/null +++ b/tests/e2e/tasks/tasks-page-redesign.spec.ts @@ -0,0 +1,254 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; +import type { ProjectSummary, SprintRecord } from '../../../src/contracts/project-management-types.js'; +import { + cleanupSprintFixture, + completeOnboarding, + createDraftSprint, + createE2eFixturePrefix, + createTaskInSprint, + ensureSelectedProject, + fetchTasksViaApi, + suppressDashboardTour, + updateTaskFields, +} from '../helpers/prepare-app'; + +async function expectNoDocumentOverflow(page: Page): Promise { + await expect.poll(() => page.evaluate(() => ({ + documentFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth, + bodyFits: document.body.scrollWidth <= document.documentElement.clientWidth, + }))).toEqual({ documentFits: true, bodyFits: true }); +} + +async function expectMenuInsideViewport(page: Page, menu: Locator): Promise { + await expect(menu).toBeInViewport(); + await expect.poll(() => menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left >= 0, + top: rect.top >= 0, + right: rect.right <= window.innerWidth, + bottom: rect.bottom <= window.innerHeight, + }; + })).toEqual({ left: true, top: true, right: true, bottom: true }); +} + +async function openTaskMenuWithKeyboard( + page: Page, + taskKey: string, + title: string, +): Promise<{ trigger: Locator; menu: Locator }> { + const trigger = page.getByRole('button', { name: `Open task actions for task ${taskKey}: ${title}`, exact: true }); + await trigger.scrollIntoViewIfNeeded(); + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + const menu = page.getByRole('menu', { name: `Actions for task ${taskKey}: ${title}`, exact: true }); + await expect(menu).toBeVisible(); + return { trigger, menu }; +} + +test.describe('Tasks page redesign acceptance', () => { + let project: ProjectSummary | null = null; + let sprint: SprintRecord | null = null; + + test.beforeEach(async ({ page, request }, testInfo) => { + await completeOnboarding(request); + await suppressDashboardTour(page); + project = await ensureSelectedProject(request, { testInfo, fixtureKey: 'tasks-redesign' }); + sprint = await createDraftSprint(request, project.id, { + testInfo, + fixtureKey: 'tasks-redesign', + goal: 'Exercise the integrated Tasks board and card acceptance contract.', + }); + }); + + test.afterEach(async ({ request }) => { + if (project && sprint) { + await cleanupSprintFixture(request, project.id, sprint.id); + } + project = null; + sprint = null; + }); + + test('keeps board, menus, mutations, realtime refresh, and overflow stable on desktop and mobile', async ({ page, request }, testInfo) => { + if (!project || !sprint) { + throw new Error('Tasks redesign fixture was not initialized.'); + } + + const prefix = createE2eFixturePrefix({ testInfo, fixtureKey: 'tasks-redesign' }); + const dependencyTitle = `${prefix} dependency ready for QA`; + const targetTitle = `${prefix} integrated menu target`; + const completedTitle = `${prefix} ${'overflow-safe-segment-'.repeat(9)}completed`; + const editedTitle = `${targetTitle} edited`; + const realtimeTitle = `${editedTitle} realtime`; + + const dependency = await createTaskInSprint(request, project.id, sprint.id, { + testInfo, + fixtureKey: 'tasks-redesign-dependency', + title: dependencyTitle, + input: { status: 'coding_completed', priority: 'high' }, + }); + const target = await createTaskInSprint(request, project.id, sprint.id, { + testInfo, + fixtureKey: 'tasks-redesign-target', + title: targetTitle, + input: { + description: 'Target-labelled menu fixture with an explicit dependency.', + status: 'pending', + priority: 'critical', + dependsOnTaskIds: [dependency.id], + }, + }); + await createTaskInSprint(request, project.id, sprint.id, { + testInfo, + fixtureKey: 'tasks-redesign-completed', + title: completedTitle, + input: { status: 'completed', priority: 'low' }, + }); + + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto(`/tasks?projectId=${encodeURIComponent(project.id)}&sprintId=${encodeURIComponent(sprint.id)}`); + await expect(page.getByRole('heading', { level: 1, name: 'Tasks' })).toBeVisible(); + + const queuedLane = page.getByRole('region', { name: /Queued lane/i }); + const progressLane = page.getByRole('region', { name: /In Progress lane/i }); + const completedLane = page.getByRole('region', { name: /Completed lane/i }); + await expect(queuedLane).toHaveAccessibleDescription(/Queued lane contains 1 task/i); + await expect(progressLane).toHaveAccessibleDescription(/In Progress lane contains 1 task/i); + await expect(completedLane).toHaveAccessibleDescription(/Completed lane contains 1 task/i); + + const desktopLaneBoxes = await Promise.all([ + queuedLane.boundingBox(), + progressLane.boundingBox(), + completedLane.boundingBox(), + ]); + expect(desktopLaneBoxes.every(Boolean)).toBe(true); + expect(desktopLaneBoxes[0]!.x).toBeLessThan(desktopLaneBoxes[1]!.x); + expect(desktopLaneBoxes[1]!.x).toBeLessThan(desktopLaneBoxes[2]!.x); + await expectNoDocumentOverflow(page); + + const targetCard = page.getByLabel(new RegExp(`^Task ${target.taskKey}:`)); + const dependencyRow = targetCard.getByRole('listitem', { + name: new RegExp(`Depends on task ${dependency.taskKey}, ready for qa\\. Blocking dependency\\.`, 'i'), + }); + await expect(dependencyRow).toContainText(dependency.taskKey); + await expect(dependencyRow).toContainText('Ready for QA'); + await expect(dependencyRow.locator('span[aria-hidden="true"]')).toHaveText([dependency.taskKey, 'Ready for QA']); + await expect(dependencyRow).toHaveAccessibleName(new RegExp(`Title: ${dependencyTitle}$`)); + + let { trigger, menu } = await openTaskMenuWithKeyboard(page, target.taskKey, targetTitle); + await expect(menu.getByRole('menuitem', { name: `Open sprint preview for task ${target.taskKey}: ${targetTitle}` })).toBeFocused(); + await expect(menu.getByRole('menuitem', { name: `Rerun task ${target.taskKey}: ${targetTitle}` })).toHaveAccessibleDescription(`Open Live to rerun task ${target.taskKey}.`); + await expectMenuInsideViewport(page, menu); + await page.keyboard.press('Escape'); + await expect(trigger).toBeFocused(); + await expect(menu).toBeHidden(); + + await page.getByRole('tab', { name: 'Show completed tasks' }).evaluate((element) => { + (element as HTMLButtonElement).click(); + }); + const pendingFilterState = await page.evaluate((taskKey) => ({ + pending: Array.from(document.querySelectorAll('[role="status"]')).some((element) => ( + element.textContent?.includes('Updating task board filters. Current cards remain visible until results settle.') + )), + previousCardVisible: document.querySelector(`[aria-label^="Task ${taskKey}:"]`) !== null, + }), target.taskKey); + expect(pendingFilterState).toEqual({ pending: true, previousCardVisible: true }); + await expect(page.getByText(completedTitle)).toBeVisible(); + await expect(targetCard).toBeHidden(); + await page.getByRole('tab', { name: 'Show all task statuses' }).click(); + await expect(targetCard).toBeVisible(); + + ({ trigger, menu } = await openTaskMenuWithKeyboard(page, target.taskKey, targetTitle)); + let pageMutationCount = 0; + page.on('request', (outgoingRequest) => { + if (outgoingRequest.method() === 'PATCH' && outgoingRequest.url().includes(`/api/tasks/${encodeURIComponent(target.id)}`)) { + pageMutationCount += 1; + } + }); + await menu.getByRole('menuitem', { name: `Edit task ${target.taskKey}: ${targetTitle}` }).click(); + await expect(page.getByRole('region', { name: 'Edit task editor' })).toBeVisible(); + await page.getByPlaceholder('Fix navigation layout shift').fill(editedTitle); + await page.getByRole('button', { name: 'Save Task' }).click(); + await expect(page.getByRole('button', { name: `Open task actions for task ${target.taskKey}: ${editedTitle}` })).toBeVisible(); + expect(pageMutationCount).toBe(1); + await expect.poll(async () => { + const tasks = await fetchTasksViaApi(request, project!.id, sprint!.id); + return tasks.find((task) => task.id === target.id)?.title; + }).toBe(editedTitle); + + const optimisticTitle = `${prefix} optimistic create`; + let releaseCreate = (): void => {}; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + let createMutationCount = 0; + await page.route(`**/api/projects/${encodeURIComponent(project.id)}/tasks`, async (route) => { + if (route.request().method() === 'POST') { + createMutationCount += 1; + await createGate; + } + await route.continue(); + }); + try { + await page.getByRole('button', { name: 'New Task' }).click(); + await page.getByPlaceholder('Fix navigation layout shift').fill(optimisticTitle); + await page.getByPlaceholder('Summarize the intent and outcome.').fill('Verify a delayed create renders exactly one optimistic card.'); + await page.getByPlaceholder('Detailed markdown instructions for the worker agent.').fill('Create fixture work locally.'); + const createResponse = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && response.url().includes(`/api/projects/${encodeURIComponent(project!.id)}/tasks`) + && response.status() === 201 + )); + await page.getByRole('button', { name: 'Create Task' }).click(); + const optimisticCard = page.getByLabel(/Task OPT-\.\.\.:/).filter({ hasText: optimisticTitle }); + await expect(optimisticCard).toBeVisible(); + await expect(optimisticCard).toContainText('Saving task changes'); + await expect(optimisticCard.getByRole('button', { name: /Open task actions for task OPT-\.\.\./ })).toHaveAttribute('aria-busy', 'true'); + releaseCreate(); + await createResponse; + await expect(optimisticCard).toHaveCount(0); + expect(createMutationCount).toBe(1); + } finally { + releaseCreate(); + await page.unroute(`**/api/projects/${encodeURIComponent(project.id)}/tasks`); + } + + await updateTaskFields(request, project.id, target.id, { title: realtimeTitle }); + await updateTaskFields(request, project.id, target.id, { title: realtimeTitle }); + const realtimeTrigger = page.getByRole('button', { name: `Open task actions for task ${target.taskKey}: ${realtimeTitle}`, exact: true }); + await expect(realtimeTrigger).toHaveCount(1); + await expect(realtimeTrigger).toBeVisible(); + await expect(page.getByRole('button', { name: `Open task actions for task ${target.taskKey}: ${editedTitle}`, exact: true })).toHaveCount(0); + + ({ trigger, menu } = await openTaskMenuWithKeyboard(page, target.taskKey, realtimeTitle)); + await menu.getByRole('menuitem', { name: `Delete task ${target.taskKey}: ${realtimeTitle}` }).click(); + const deleteDialog = page.getByRole('dialog', { name: 'Delete Task' }); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(trigger).toBeFocused(); + await expect.poll(async () => { + const tasks = await fetchTasksViaApi(request, project!.id, sprint!.id); + return tasks.filter((task) => task.id === target.id).length; + }).toBe(1); + expect(pageMutationCount).toBe(1); + + await page.setViewportSize({ width: 390, height: 844 }); + await expectNoDocumentOverflow(page); + const mobileLaneBoxes = await Promise.all([ + queuedLane.boundingBox(), + progressLane.boundingBox(), + completedLane.boundingBox(), + ]); + expect(mobileLaneBoxes.every(Boolean)).toBe(true); + expect(mobileLaneBoxes[0]!.y).toBeLessThan(mobileLaneBoxes[1]!.y); + expect(mobileLaneBoxes[1]!.y).toBeLessThan(mobileLaneBoxes[2]!.y); + + ({ trigger, menu } = await openTaskMenuWithKeyboard(page, target.taskKey, realtimeTitle)); + await expectMenuInsideViewport(page, menu); + await expect(menu.getByRole('menuitem', { name: `Open sprint preview for task ${target.taskKey}: ${realtimeTitle}` })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(trigger).toBeFocused(); + await expectNoDocumentOverflow(page); + }); +});