diff --git a/apps/web/components/task/task-title-hover-card.tsx b/apps/web/components/task/task-title-hover-card.tsx
index 711a55110e..1ce84a5ba9 100644
--- a/apps/web/components/task/task-title-hover-card.tsx
+++ b/apps/web/components/task/task-title-hover-card.tsx
@@ -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({
{title}
-
+
);
@@ -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 (
-
+
{children}
);
diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts
index abd6c4b8e6..0b2a6c9fbd 100644
--- a/apps/web/e2e/pages/session-page.ts
+++ b/apps/web/e2e/pages/session-page.ts
@@ -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 {
- 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 {
+ 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 {
- 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");
diff --git a/apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts b/apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts
index bb7770de14..c9391ac7e1 100644
--- a/apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts
+++ b/apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts
@@ -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 ({
diff --git a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts
index 9c619c0acb..f20b423076 100644
--- a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts
+++ b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts
@@ -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);
diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
index f96c99a1fd..2d358446f2 100644
--- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
@@ -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();
diff --git a/apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts b/apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts
index 73d46d8436..572cdbdd22 100644
--- a/apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts
+++ b/apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts
@@ -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);
});
});
diff --git a/apps/web/hooks/use-lazy-load-sentinel.test.ts b/apps/web/hooks/use-lazy-load-sentinel.test.ts
index b0c6f89ffd..2ba8e577d1 100644
--- a/apps/web/hooks/use-lazy-load-sentinel.test.ts
+++ b/apps/web/hooks/use-lazy-load-sentinel.test.ts
@@ -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,
}),
);
diff --git a/docs/plans/rich-task-title-previews/plan.md b/docs/plans/rich-task-title-previews/plan.md
index 11c21b021b..f8b323e067 100644
--- a/docs/plans/rich-task-title-previews/plan.md
+++ b/docs/plans/rich-task-title-previews/plan.md
@@ -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)
diff --git a/docs/plans/rich-task-title-previews/task-04-kanban-only-scope.md b/docs/plans/rich-task-title-previews/task-04-kanban-only-scope.md
new file mode 100644
index 0000000000..b13574f455
--- /dev/null
+++ b/docs/plans/rich-task-title-previews/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.
diff --git a/docs/specs/tasks/requirements/rich-task-title-previews.md b/docs/specs/tasks/requirements/rich-task-title-previews.md
index 90ae0dd21c..d683ffaa41 100644
--- a/docs/specs/tasks/requirements/rich-task-title-previews.md
+++ b/docs/specs/tasks/requirements/rich-task-title-previews.md
@@ -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