fix(web): scope task title previews to Kanban (#2939)

This commit is contained in:
Carlos Florêncio
2026-08-24 19:24:15 +00:00
committed by GitHub
parent 5f5d3337cf
commit 4b7ea239d4
14 changed files with 220 additions and 178 deletions
+3 -6
View File
@@ -6,7 +6,6 @@ import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon";
import { TaskRowMetadata } from "@/components/task/task-row-plugin-slots";
import { MRTaskIcon } from "@/components/gitlab/mr-task-icon";
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
import { useTaskPendingInput, type PendingInput } from "@/hooks/use-task-pending-input";
import { getTaskStateIcon } from "@/lib/ui/state-icons";
import type { Repository, Task } from "@/lib/types/http";
@@ -45,11 +44,9 @@ function PrimaryTaskLine({
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
})}
<TaskTitleHoverCard taskId={task.id} title={task.title}>
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
</TaskTitleHoverCard>
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
{showContributions && (
<span
className="inline-flex items-center gap-1"
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const mockAppState = {
workspaces: { activeId: "ws-1" },
kanban: { tasks: [] as Array<{ id: string; title: string }> },
kanban: { tasks: [] as Array<{ id: string; title: string; parentTaskId?: string }> },
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: string }> }> },
taskPRs: { byTaskId: {} as Record<string, unknown> },
};
@@ -49,6 +49,7 @@ function SlotPropsProbe({ testId, slotProps }: { testId: string; slotProps?: unk
afterEach(() => {
cleanup();
mockAppState.kanban.tasks = [];
pluginRegistry.unregisterPlugin(NOTES_PLUGIN_ID);
pluginRegistry.unregisterPlugin(SECOND_PLUGIN_ID);
});
@@ -171,7 +172,14 @@ describe("KanbanCardBody — title hover card gating", () => {
expect(title.getAttribute("data-slot")).not.toBe("hover-card-trigger");
});
it("mounts the hover card trigger around the title when enableTitleHover is set", () => {
it("does not mount a hover card trigger for a childless task", () => {
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
const title = screen.getByTestId(TITLE_TEST_ID);
expect(title.closest('[data-testid="task-title-preview-trigger"]')).toBeNull();
});
it("mounts the hover card trigger when an active direct subtask exists", () => {
mockAppState.kanban.tasks = [{ id: "child-1", title: "Child", parentTaskId: TASK.id }];
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
const title = screen.getByTestId(TITLE_TEST_ID);
expect(title.closest('[data-testid="task-title-preview-trigger"]')).not.toBeNull();
+4 -16
View File
@@ -27,9 +27,8 @@ import { TaskRowMetadata } from "./task-row-plugin-slots";
import { classifyTask } from "./task-classify";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { useTranslation } from "react-i18next";
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
import { TaskItemComparisonUnavailable } from "./task-item-comparison-unavailable";
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
import { TaskMenuButton } from "./task-item-menu-button";
import { TaskItemLeadingBadges } from "./task-item-leading-badges";
@@ -326,19 +325,8 @@ function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpe
);
}
function TaskItemTitle({ taskId, title }: { taskId?: string; title: string }) {
// w-full: ScrollOnOverflow's root is inline-block, so once it sits inside
// the title-preview trigger's <button> (task-title-hover-card.tsx) rather
// than being the flex row's direct child, shrink-to-fit sizing lets it grow
// past the button's flex-shrunk width instead of clipping to it — losing
// the overflow the hover-scroll marquee depends on.
const content = <ScrollOnOverflow className="min-w-0 w-full">{title}</ScrollOnOverflow>;
if (!taskId) return content;
return (
<TaskTitleHoverCard taskId={taskId} title={title} side="right" align="start">
{content}
</TaskTitleHoverCard>
);
function TaskItemTitle({ title }: { title: string }) {
return <ScrollOnOverflow className="min-w-0 w-full">{title}</ScrollOnOverflow>;
}
function TaskItemContent({
@@ -390,7 +378,7 @@ function TaskItemContent({
return (
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight">
<TaskItemTitle taskId={taskId} title={title} />
<TaskItemTitle title={title} />
<TaskItemLeadingBadges
autopilot={autopilot}
isPinned={isPinned}
@@ -108,7 +108,10 @@ function subtaskRow(childId: string): HTMLElement {
describe("TaskTitleHoverCard", () => {
it("AC12: shows the full title in bold (font-semibold) when opened", async () => {
renderCard([makeTask({ id: "parent-1" })]);
renderCard([
makeTask({ id: "parent-1" }),
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
]);
await openCard();
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
@@ -243,11 +246,11 @@ describe("TaskTitleHoverCard — GitLab MR subtasks", () => {
});
describe("TaskTitleHoverCard — subtask cap and dismissal", () => {
it("AC15: opens with only the bold title when there are no subtasks", async () => {
it("AC15: does not mount a trigger when there are no subtasks", () => {
renderCard([makeTask({ id: "parent-1" })]);
await openCard();
expect(screen.queryAllByTestId("task-title-hover-subtasks")).toHaveLength(0);
expect(screen.queryByTestId("task-title-preview-trigger")).toBeNull();
expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull();
});
it("AC17: caps subtask rows at 12 and shows a +N more line", async () => {
@@ -290,7 +293,10 @@ describe("TaskTitleHoverCard — subtask cap and dismissal", () => {
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [makeTask({ id: "parent-1" })],
tasks: [
makeTask({ id: "parent-1" }),
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
],
},
}}
>
@@ -325,7 +331,14 @@ describe("TaskTitleHoverCard — keyboard propagation", () => {
render(
<StateProvider
initialState={{
kanban: { workflowId: "wf-1", steps: [], tasks: [makeTask({ id: "parent-1" })] },
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [
makeTask({ id: "parent-1" }),
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
],
},
}}
>
<div onKeyDown={onParentKeyDown}>
@@ -4,7 +4,7 @@ import { useRef, type FocusEvent, type MouseEvent, type ReactNode } from "react"
import { useTranslation } from "react-i18next";
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTaskSubtasks } from "@/hooks/domains/kanban/use-task-subtasks";
import { useTaskSubtasks, type TaskSubtask } from "@/hooks/domains/kanban/use-task-subtasks";
import { useHoverPopover } from "@/components/integrations/use-hover-popover";
import { TaskSubtaskRow } from "./task-subtask-row";
@@ -12,9 +12,8 @@ const MAX_VISIBLE_SUBTASKS = 12;
const OPEN_DELAY_MS = 200;
const CLOSE_DELAY_MS = 100;
function SubtasksSection({ taskId }: { taskId: string }) {
function SubtasksSection({ subtasks }: { subtasks: TaskSubtask[] }) {
const { t } = useTranslation();
const subtasks = useTaskSubtasks(taskId);
if (subtasks.length === 0) return null;
const visible = subtasks.slice(0, MAX_VISIBLE_SUBTASKS);
@@ -43,15 +42,15 @@ function SubtasksSection({ taskId }: { taskId: string }) {
}
function DesktopTaskTitlePreview({
taskId,
title,
children,
subtasks,
side,
align,
}: {
taskId: string;
title: string;
children: ReactNode;
subtasks: TaskSubtask[];
side: "top" | "right" | "bottom" | "left";
align: "start" | "center" | "end";
}) {
@@ -131,7 +130,7 @@ function DesktopTaskTitlePreview({
<div className="text-pretty break-words text-sm font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
{title}
</div>
<SubtasksSection taskId={taskId} />
<SubtasksSection subtasks={subtasks} />
</PopoverContent>
</Popover>
);
@@ -152,11 +151,12 @@ export function TaskTitleHoverCard({
align?: "start" | "center" | "end";
}) {
const { isFinePointer } = useResponsiveBreakpoint();
const subtasks = useTaskSubtasks(taskId);
if (!isFinePointer) return <>{children}</>;
if (!isFinePointer || subtasks.length === 0) return <>{children}</>;
return (
<DesktopTaskTitlePreview taskId={taskId} title={title} side={side} align={align}>
<DesktopTaskTitlePreview title={title} subtasks={subtasks} side={side} align={align}>
{children}
</DesktopTaskTitlePreview>
);
+20 -10
View File
@@ -309,20 +309,32 @@ export class SessionPage {
.waitFor({ state: "hidden", timeout });
}
/**
* Return the foreground panel for a test id.
*
* Dockview keeps background task panels mounted while switching tasks. A
* page-level DOM query can therefore read a stale terminal buffer even
* though the visible panel has already connected.
*/
private activePanel(testId: string): Locator {
return this.page.locator(`[data-testid="${testId}"]:visible`).first();
}
/**
* Read the text content of an xterm.js terminal buffer.
* xterm renders to canvas/WebGL so text isn't in the DOM. Uses the
* __xtermReadBuffer() helper exposed on the terminal container element.
*/
private readXtermBuffer(testId: string): Promise<string> {
return this.page.evaluate((tid) => {
const panel = document.querySelector(`[data-testid="${tid}"]`);
if (!panel) return "";
const xtermEl = panel.querySelector(".xterm");
private async readXtermBuffer(testId: string): Promise<string> {
const panel = this.activePanel(testId);
if ((await panel.count()) === 0) return "";
return panel.evaluate((panelElement) => {
const xtermEl = panelElement.querySelector(".xterm");
type XC = HTMLElement & { __xtermReadBuffer?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
return container?.__xtermReadBuffer?.() ?? "";
}, testId);
});
}
/**
@@ -1282,9 +1294,7 @@ export class SessionPage {
* Use this to detect the "terminal hangs forever on Connecting" bug.
*/
async expectTerminalConnected(timeout = TERMINAL_READY_TIMEOUT): Promise<void> {
await this.page
.locator("[data-testid='terminal-panel']:visible")
.first()
await this.activePanel("terminal-panel")
.getByTestId("passthrough-loading")
.waitFor({ state: "hidden", timeout });
}
@@ -1302,7 +1312,7 @@ export class SessionPage {
})
.toBe(true);
const xterm = this.terminal.locator(".xterm");
const xterm = this.activePanel("terminal-panel").locator(".xterm");
await xterm.click();
await this.page.keyboard.type(command);
await this.page.keyboard.press("Enter");
@@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
/**
* Seeds a parent task with two subtasks. No agent is started (mirrors the
@@ -90,7 +91,7 @@ test.describe("Task title hover card on the Kanban card", () => {
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
});
test("AC15: a task with no subtasks still opens the hover card with only the bold title", async ({
test("AC15: a task with no subtasks does not open a title hover card", async ({
testPage,
apiClient,
seedData,
@@ -108,10 +109,13 @@ test.describe("Task title hover card on the Kanban card", () => {
await expect(card).toBeVisible({ timeout: 45_000 });
await card.getByTestId("task-card-title").hover();
const hoverCard = testPage.getByTestId("task-title-hover-card");
await expect(hoverCard).toBeVisible();
await expect(hoverCard).toContainText("Childless title hover task");
await expect(hoverCard.getByTestId("task-title-hover-subtasks")).toHaveCount(0);
await dwell(
testPage,
300,
"negative-assertion",
"a childless Kanban card must not open a title preview",
);
await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
});
test("the open hover card does not swallow the click that opens the parent task", async ({
@@ -435,9 +435,8 @@ test.describe("PR status badge", () => {
).toBe(true);
await taskRow.focus();
// Tab once lands on the row's title-preview trigger (now keyboard-operable,
// see task-title-hover-card.tsx), tab again reaches the PR badge.
await testPage.keyboard.press("Tab");
// The sidebar no longer mounts the task-title preview, so the first tab
// after the row reaches the PR badge.
await testPage.keyboard.press("Tab");
await expect(icon).toBeFocused();
const focusedSummary = visibleTaskPRSummary(testPage);
@@ -790,7 +790,9 @@ test.describe("Task creation from GitHub URL", () => {
await startBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
// Task B should also succeed (this would have failed before the fix).
// Task B follows a full navigation away from Task A. Dockview can keep
// Task A's terminal mounted while Task B hydrates, so this also guards
// SessionPage against reading a stale, hidden terminal buffer.
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const sessionB = new SessionPage(testPage);
await sessionB.waitForLoad();
@@ -1,17 +1,13 @@
import { test, expect } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
/**
* The kanban card is covered by tests/kanban/task-title-hover-subtasks.spec.ts.
* The title hover also ships on the two other surfaces named in the plan (the
* /tasks rich list row and the AppSidebar task row), and those read their
* subtasks from the same store slices the kanban board fills. Whether those
* slices are populated on a page that never mounts the board is exactly the
* kind of thing a unit test with a hand-seeded store cannot answer, so it is
* asserted here against a real backend.
* The title preview is intentionally a Kanban-only affordance. These tests
* keep the non-Kanban surfaces from regressing back to title/subtask popovers.
*/
// Backend caps task titles at 60 characters, so these sit just under it.
const PARENT_TITLE = "Surface hover parent with a title long enough to clamp";
const CHILD_ONE_TITLE = "Surface child one with its own long title";
const CHILD_TWO_TITLE = "Surface child two";
@@ -21,38 +17,45 @@ async function seedParentWithSubtasks(apiClient: ApiClient, seedData: SeedData)
workspace_id: seedData.workspaceId,
enable_preview_on_click: false,
});
// No agent is started: an auto-started session's on_turn_complete would move
// the task mid-test and detach the hover target from under the assertion.
const parent = await apiClient.createTask(seedData.workspaceId, PARENT_TITLE, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const childOne = await apiClient.createTask(seedData.workspaceId, CHILD_ONE_TITLE, {
await apiClient.createTask(seedData.workspaceId, CHILD_ONE_TITLE, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
parent_id: parent.id,
workspace_mode: "inherit_parent",
});
const childTwo = await apiClient.createTask(seedData.workspaceId, CHILD_TWO_TITLE, {
await apiClient.createTask(seedData.workspaceId, CHILD_TWO_TITLE, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
parent_id: parent.id,
workspace_mode: "inherit_parent",
});
return { parent, childOne, childTwo };
}
test.describe("Task title hover card on the non-Kanban surfaces", () => {
test("AC12/AC13/AC14: the /tasks rich list row shows the full title and clickable subtask rows", async ({
async function assertNoTitlePreview(testPage: Page) {
await dwell(
testPage,
300,
"negative-assertion",
"non-Kanban task titles must not open a title preview",
);
await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
}
test.describe("Task title hover card on non-Kanban surfaces", () => {
test("the /tasks rich list does not show a title preview for parent tasks", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { parent, childOne } = await seedParentWithSubtasks(apiClient, seedData);
await seedParentWithSubtasks(apiClient, seedData);
await testPage.goto("/tasks");
await testPage.waitForLoadState("networkidle");
@@ -65,97 +68,16 @@ test.describe("Task title hover card on the non-Kanban surfaces", () => {
await expect(parentRow).toBeVisible({ timeout: 45_000 });
await parentRow.getByTestId("tasks-list-row-title").hover();
const hoverCard = testPage.getByTestId("task-title-hover-card");
await expect(hoverCard).toBeVisible();
await expect(hoverCard).toContainText(PARENT_TITLE);
// AC13: the subtask rows are the point of the card. On this page the task
// list is local component state, so an empty list here would mean the
// hover silently degrades to a title-only tooltip.
const rowOne = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
await expect(rowOne).toBeVisible();
await expect(rowOne).toContainText(CHILD_ONE_TITLE);
await expect(hoverCard).toContainText(CHILD_TWO_TITLE);
// AC20: opening the card must not push the document sideways.
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
// AC14: the row navigates to the subtask, not to the parent it sits on.
await rowOne.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
expect(testPage.url()).not.toContain(parent.id);
await assertNoTitlePreview(testPage);
});
test("AC16: an archived subtask drops out of the hover card, the live one stays", async ({
test("the AppSidebar task row does not show a title preview for parent tasks", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { childOne, childTwo } = await seedParentWithSubtasks(apiClient, seedData);
await apiClient.archiveTask(childTwo.id);
await testPage.goto("/tasks");
await testPage.waitForLoadState("networkidle");
const parentRow = testPage
.getByTestId("tasks-list")
.getByTestId("tasks-list-row")
.filter({ hasText: PARENT_TITLE })
.first();
await expect(parentRow).toBeVisible({ timeout: 45_000 });
await parentRow.getByTestId("tasks-list-row-title").hover();
const hoverCard = testPage.getByTestId("task-title-hover-card");
await expect(hoverCard.getByTestId(`task-subtask-row-${childOne.id}`)).toBeVisible();
// The archived child must not keep advertising itself from the parent's card.
await expect(hoverCard.getByTestId(`task-subtask-row-${childTwo.id}`)).toHaveCount(0);
await expect(hoverCard).not.toContainText(CHILD_TWO_TITLE);
});
test("keyboard activation opens a child task without opening its parent", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { parent, childOne } = await seedParentWithSubtasks(apiClient, seedData);
await testPage.goto("/tasks");
await testPage.waitForLoadState("networkidle");
const parentRow = testPage
.getByTestId("tasks-list")
.getByTestId("tasks-list-row")
.filter({ hasText: PARENT_TITLE });
await expect(parentRow).toHaveCount(1, { timeout: 45_000 });
const trigger = parentRow.getByTestId("task-title-preview-trigger");
await trigger.focus();
await trigger.press("Enter");
const hoverCard = testPage.getByTestId("task-title-hover-card");
const childRow = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
await expect(hoverCard).toBeVisible();
await expect(childRow).toBeFocused();
await childRow.press("Enter");
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
expect(testPage.url()).not.toContain(parent.id);
});
test("AC12/AC13: the AppSidebar task row shows the full title and its subtask rows", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { childOne } = await seedParentWithSubtasks(apiClient, seedData);
await seedParentWithSubtasks(apiClient, seedData);
await testPage.goto("/tasks");
await testPage.waitForLoadState("networkidle");
@@ -168,11 +90,6 @@ test.describe("Task title hover card on the non-Kanban surfaces", () => {
await expect(sidebarRow).toBeVisible({ timeout: 45_000 });
await sidebarRow.getByText(PARENT_TITLE, { exact: false }).first().hover();
const hoverCard = testPage.getByTestId("task-title-hover-card");
await expect(hoverCard).toBeVisible();
await expect(hoverCard).toContainText(PARENT_TITLE);
await expect(hoverCard.getByTestId(`task-subtask-row-${childOne.id}`)).toBeVisible();
await expect(hoverCard).toContainText(CHILD_TWO_TITLE);
await assertNoTitlePreview(testPage);
});
});
@@ -252,10 +252,13 @@ describe("useLazyLoadSentinel — re-arm, disarm, and stale completions", () =>
// The sentinel was unobserved before the await; the load resolved
// positively, so it is re-observed (still current).
expect(records[0].unobserved).toContain(node);
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
await act(async () => {
await vi.waitFor(() => expect(loadMore).toHaveBeenCalledTimes(2));
});
expect(records[0].targets.filter((t) => t === node).length).toBeGreaterThanOrEqual(1);
// Still intersecting: the next page auto-loads without a scroll-away or
// a second IntersectionObserver entry.
expect(loadMore).toHaveBeenCalledTimes(2);
});
});
@@ -291,11 +294,11 @@ describe("useLazyLoadSentinel — stickToBottomWhileLoading", () => {
// Rows are appended while the load is in flight (scrollHeight grows to
// 800); the user stays at the old bottom. The settle must scroll the
// scroller back to the new bottom so the sentinel stays intersecting.
expect(loadMore).toHaveBeenCalledTimes(1);
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 800 });
await act(async () => {
resolveLoad(20);
});
expect(loadMore).toHaveBeenCalledTimes(1);
// Browser-faithful assertion: jsdom stores the raw write (800) while a
// real browser clamps scrollTop to scrollHeight - clientHeight (400); the
// invariant is "pinned at the bottom", so assert that instead of the
@@ -323,8 +326,6 @@ describe("useLazyLoadSentinel — pin refresh before a load", () => {
);
const { result } = renderHook(() =>
useLazyLoadSentinel(scrollRef, true, false, false, loadMore, {
rearmWhileIntersecting: true,
joinInFlightWhileLoading: true,
stickToBottomWhileLoading: true,
}),
);
+36 -6
View File
@@ -8,9 +8,13 @@ status: complete
## Overview
Add one reusable title preview to the Kanban card, sidebar task row, and rich
task list. Reuse the existing task and contribution stores. Keep provider data
derivation separate from the shared change-request presentation.
The original implementation added one reusable title preview to the Kanban
card, sidebar task row, and rich task list. This follow-up narrows the
user-facing scope to Kanban parent cards: the title preview is rendered only
when active direct subtasks exist, and the sidebar and rich task list keep their
normal title navigation without a popup. Reuse the existing task and
contribution stores and keep provider data derivation separate from the shared
change-request presentation.
## Backend
@@ -34,6 +38,15 @@ GitHub and GitLab components derive provider-specific rows and presentation data
`useWorkspaceMRs` shares one in-flight workspace request between mounted consumers.
It permits later refreshes and clears the workspace after failure.
### Scope refinement
`apps/web/components/task/task-item.tsx` and
`apps/web/app/tasks/rich-task-list-row.tsx` render their existing title content
without `TaskTitleHoverCard`. The shared preview component gates the Kanban
trigger on the presence of an active direct subtask, while preserving
fine-pointer and coarse-pointer behavior. The separate GitHub PR and GitLab MR
badge hovers remain unchanged.
## Tests
- **What:** pointer and keyboard preview behavior, subtask limits, and navigation isolation.
@@ -45,16 +58,27 @@ It permits later refreshes and clears the workspace after failure.
- **What:** provider-neutral status presentation.
**File:** GitHub and GitLab status-summary render tests.
**How:** render tests with provider-specific presentation data.
- **What:** Kanban-only preview scope and childless-card gating.
**File:** `apps/web/components/task/task-title-hover-card.test.tsx` and
`apps/web/components/kanban-card-content.test.tsx`.
**How:** component tests verify that a parent with active subtasks exposes a
trigger, while a childless task does not.
## E2E tests
- **Scenario:** A pointer opens a preview on each desktop task surface.
**File:** `apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts` and
`apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts`.
- **Scenario:** A fine pointer opens a preview on a Kanban parent card.
**File:** `apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts`.
**What to verify:** full title, direct subtasks, child navigation, and no overflow.
- **Scenario:** A coarse pointer opens the task directly.
**File:** `apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts`.
**What to verify:** no hover content and task-tree access through the mobile switcher.
- **Scenario:** Non-Kanban task titles do not open the preview.
**File:** `apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts`.
**What to verify:** hovering parent titles in `/tasks` and the AppSidebar
leaves `task-title-hover-card` absent.
- **Scenario:** A childless Kanban card does not open the preview.
**File:** `apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts`.
**What to verify:** hovering the title leaves the preview absent.
## Verification results
@@ -63,9 +87,15 @@ It permits later refreshes and clears the workspace after failure.
- The desktop Chromium flow passed four tests.
- The mobile Chrome flow passed one test.
- `git diff --check` passed.
- Follow-up scope refinement: passed. The focused Vitest run passed 22 tests in
two files. The managed desktop Chromium run passed five tests, including the
Kanban parent preview and no-preview assertions for childless, `/tasks`, and
sidebar surfaces. The managed mobile Chrome run passed one direct-navigation
test. Web typecheck and ESLint passed, and `git diff --check` passed.
## Implementation order
- [x] [Task 01: Preview and hierarchy](task-01-preview-and-hierarchy.md)
- [x] [Task 02: Contribution status and state](task-02-contribution-status-and-state.md)
- [x] [Task 03: End-to-end verification](task-03-end-to-end-verification.md)
- [x] [Task 04: Scope preview to Kanban parents](task-04-kanban-only-scope.md)
@@ -0,0 +1,55 @@
---
id: "04-kanban-only-scope"
title: "Scope preview to Kanban parents"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/rich-task-title-previews.md"
---
# Task 04: Scope preview to Kanban parents
- **Acceptance:** Kanban parent cards with active direct subtasks retain the
fine-pointer title preview and its existing keyboard/subtask navigation.
- **Acceptance:** Childless Kanban cards, left-sidebar task rows, and `/tasks`
rich-list rows do not mount or open the task-title preview.
- **Acceptance:** Coarse-pointer Kanban navigation and separate GitHub/GitLab
contribution-badge hovers remain unchanged.
- **Verification:**
`cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx components/kanban-card-content.test.tsx`
and
`cd apps/web && pnpm e2e:run tests/kanban/task-title-hover-subtasks.spec.ts tests/task/task-title-hover-surfaces.spec.ts`
and
`cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts`.
- **Files touched:** `apps/web/components/task/task-item.tsx`,
`apps/web/components/task/task-item-content.tsx`,
`apps/web/app/tasks/rich-task-list-row.tsx`,
`apps/web/components/task/task-title-hover-card.tsx`,
`apps/web/components/task/task-title-hover-card.test.tsx`,
`apps/web/components/kanban-card-content.test.tsx`, and the affected
desktop/mobile Playwright specifications, and
`apps/web/e2e/tests/pr/pr-status-badge.spec.ts`.
- **Dependencies:** None. The existing title-preview implementation and task
hierarchy hooks are available.
- **Parallelism:** sequential.
- **Inputs:** the `What`, `Failure modes`, `Scenarios`, and `Out of scope`
sections of the spec; the `Scope refinement`, `Tests`, and `E2E Tests`
sections of `plan.md`; `apps/web/AGENTS.md`; and the `/mobile-parity`
contract for preserving coarse-pointer direct navigation.
## Results
- `cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx components/kanban-card-content.test.tsx` passed: 2 files and 22 tests.
- `cd apps/web && pnpm e2e:run tests/kanban/task-title-hover-subtasks.spec.ts tests/task/task-title-hover-surfaces.spec.ts` passed: 5 Chromium tests.
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts` passed: 1 mobile Chrome test.
- `cd apps/web && pnpm run typecheck` passed.
- `cd apps/web && pnpm run lint` passed.
- `git diff --check` passed.
- The managed E2E runner cleaned its temporary backend, fixture repository, and test data. No external side effects remain.
- PR fixup extracted the sidebar task-content renderer into
`apps/web/components/task/task-item-content.tsx` to satisfy the repository's
600-line file limit. This is an internal, behavior-preserving refactor, so
the product behavior and acceptance criteria are unchanged.
- PR fixup updated the existing PR badge E2E assertion for the removed sidebar
title-preview tab stop. The focused managed Chromium test passed: 1 test.
@@ -25,14 +25,19 @@ This document is the migrated task-system source for the capability. The source
## Why
Task titles can be too long for a task row. Users also need a fast way to see
the direct subtasks and contribution state without opening the parent task.
Task titles can be too long for a Kanban card. Users also need a fast way to
see the direct subtasks and contribution state without opening a parent card.
Repeating a title-only preview in the left sidebar or the task list adds little
value and competes with those surfaces' navigation.
## What
- A fine-pointer task row shows a preview after the user points at its title.
- A fine-pointer Kanban card title shows a preview only when the task has at
least one active, direct subtask.
- The preview shows the full title and as many as 12 direct, active subtasks.
- Each subtask row shows its task state and its GitHub or GitLab contribution state.
- The left sidebar and `/tasks` rich list do not mount a task-title preview,
including for parent tasks with subtasks.
- A keyboard user can focus the title trigger and open the same preview.
- A keyboard user can open a subtask without opening the parent task.
- A coarse-pointer device keeps the title's direct navigation action.
@@ -59,13 +64,23 @@ Provider-specific code supplies labels, icons, test identifiers, and status rows
## Failure modes
- If a task has no active direct subtask, the preview shows only the full title.
- If a Kanban task has no active direct subtask, the title has no preview
trigger and keeps the card's normal navigation behavior.
- A task title in the left sidebar or `/tasks` rich list has no preview,
regardless of its subtask count.
- If GitLab refresh fails, the UI removes cached merge-request data for that workspace.
- If the device has no fine pointer, the UI does not mount a hover preview.
## Scenarios
- **GIVEN** a clipped title, **WHEN** a user points at it, **THEN** the preview shows the full title.
- **GIVEN** a clipped Kanban title with at least one active direct subtask,
**WHEN** a user points at it, **THEN** the preview shows the full title and
the active direct subtasks.
- **GIVEN** a childless Kanban card, **WHEN** a user points at its title,
**THEN** no title preview opens and the card's normal navigation remains
available.
- **GIVEN** a parent task in the left sidebar or `/tasks` rich list, **WHEN** a
user points at its title, **THEN** no task-title preview opens.
- **GIVEN** a parent with active subtasks, **WHEN** the preview opens, **THEN** it shows each direct subtask up to the limit.
- **GIVEN** a focused title trigger, **WHEN** a user presses Enter, **THEN** the preview opens and moves focus into its interactive content.
- **GIVEN** an open preview, **WHEN** a user presses Enter on a subtask, **THEN** the app opens the subtask and not the parent.
@@ -77,6 +92,9 @@ Provider-specific code supplies labels, icons, test identifiers, and status rows
- The feature does not add nested preview levels.
- The feature does not add backend status projection fields.
- The feature does not show the task-title preview in the left sidebar or
`/tasks` rich list.
- The feature does not change the separate GitHub PR or GitLab MR badge hovers.
- The feature does not change mobile task navigation.
## Implementation plan