) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) hover.onContentLeave();
+ };
+
+ return (
+
+
+ {
+ // Only swallow keys while the preview is open (protects it from
+ // the card/row's own keyboard shortcuts, e.g. drag pickup).
+ // Escape must always fall through to close the surface it sits
+ // on (kanban preview panel, dialog, ...) even when the trigger
+ // merely has focus from a plain click and the preview isn't open.
+ if (!hover.open && event.key === "Escape") return;
+ event.stopPropagation();
+ }}
+ onClick={handleTriggerClick}
+ className="min-w-0 max-w-full cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
+ >
+ {children}
+
+
+ {
+ if (!contentRef.current?.contains(document.activeElement)) hover.onContentLeave();
+ }}
+ onFocusCapture={hover.onContentEnter}
+ onBlurCapture={handleContentBlur}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ if (keyboardSessionRef.current) {
+ contentRef.current
+ ?.querySelector(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ )
+ ?.focus();
+ }
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ if (keyboardSessionRef.current) triggerRef.current?.focus();
+ keyboardSessionRef.current = false;
+ }}
+ onClick={(event) => event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ >
+
+ {title}
+
+
+
+
+ );
+}
+
+/** Interactive desktop preview with direct mobile navigation as the coarse-pointer fallback. */
+export function TaskTitleHoverCard({
+ taskId,
+ title,
+ children,
+ side = "bottom",
+ align = "start",
+}: {
+ taskId: string;
+ title: string;
+ children: ReactNode;
+ side?: "top" | "right" | "bottom" | "left";
+ align?: "start" | "center" | "end";
+}) {
+ const { isFinePointer } = useResponsiveBreakpoint();
+
+ if (!isFinePointer) return <>{children}>;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/e2e/tests/gitlab/mr-badge-sidebar-and-list.spec.ts b/apps/web/e2e/tests/gitlab/mr-badge-sidebar-and-list.spec.ts
new file mode 100644
index 0000000000..dc919d4a42
--- /dev/null
+++ b/apps/web/e2e/tests/gitlab/mr-badge-sidebar-and-list.spec.ts
@@ -0,0 +1,178 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
+import type { ApiClient } from "../../helpers/api-client";
+import type { SeedData } from "../../fixtures/test-base";
+
+/**
+ * The AppSidebar (app/layout.tsx) never unmounts across a client-side route
+ * change, and this branch gave it both its own MR badges and its own
+ * `useWorkspaceMRs` call. `useWorkspaceMRs(null)` resets the whole
+ * `taskMRs.byWorkspaceId` map — not just one workspace — and the hook's
+ * per-instance `fetchedRef` then stops the sidebar refilling what was deleted.
+ *
+ * So a second consumer that merely does not *want* MRs can blank the sidebar's
+ * badges. `/tasks` was exactly that consumer: it gated its call on
+ * `tasksListShowDetails`, which is off by default.
+ *
+ * A full page load hides this — both consumers mount in one commit, the reset
+ * runs synchronously before the sidebar's fetch resolves, and the late response
+ * repopulates. Only a client-side navigation, with the sidebar already hydrated,
+ * exposes it. Hence the command palette below rather than `page.goto`.
+ */
+
+let nextIID = 700;
+
+function nextMRIID(): number {
+ nextIID += 1;
+ return nextIID;
+}
+
+async function seedAndLinkMR(apiClient: ApiClient, seedData: SeedData, taskId: string) {
+ const iid = nextMRIID();
+ const now = new Date().toISOString();
+ await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
+ {
+ iid,
+ id: iid + 10_000,
+ project_id: 101,
+ title: `Sidebar cache fixture MR ${iid}`,
+ url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ state: "open",
+ head_branch: `feature/cache-${iid}`,
+ head_sha: `sha-${iid}`,
+ base_branch: "main",
+ author_username: "contributor",
+ project_namespace: "platform",
+ project_path: GITLAB_PROJECT,
+ body: "",
+ draft: false,
+ merge_status: "can_be_merged",
+ has_conflicts: false,
+ additions: 1,
+ deletions: 1,
+ reviewers: [],
+ assignees: [],
+ created_at: now,
+ updated_at: now,
+ },
+ ]);
+ await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
+ task_id: taskId,
+ repository_id: seedData.repositoryId,
+ mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
+ });
+ return iid;
+}
+
+async function configureGitLab(apiClient: ApiClient, seedData: SeedData) {
+ await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
+ await apiClient.updateRepository(seedData.repositoryId, {
+ provider: "gitlab",
+ provider_host: GITLAB_HOST,
+ provider_owner: "platform",
+ provider_name: "kandev",
+ });
+}
+
+// No agent: an auto-started session's on_turn_complete would move the task
+// mid-test (see mr-task-card-badge.spec.ts for the full rationale).
+async function seedTask(apiClient: ApiClient, seedData: SeedData, title: string) {
+ return apiClient.createTask(seedData.workspaceId, title, {
+ description: "MR badge fixture task",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+}
+
+test.describe("GitLab MR badge on the sidebar and the /tasks list", () => {
+ test("the sidebar's MR badge survives navigating into /tasks with task details off", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ // tasksListShowDetails is deliberately left at its default (off) — that is
+ // the configuration the bug needed, so setting it here would test nothing.
+ await apiClient.saveUserSettings({
+ workspace_id: seedData.workspaceId,
+ enable_preview_on_click: false,
+ });
+ await configureGitLab(apiClient, seedData);
+
+ const task = await seedTask(apiClient, seedData, "Sidebar MR cache task");
+ await seedAndLinkMR(apiClient, seedData, task.id);
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
+
+ // The sidebar row's badge, not the board card's — the board has its own
+ // useWorkspaceMRs via kanban-board.tsx and would recover on its own.
+ const sidebarBadge = testPage.getByTestId("app-sidebar").getByTestId(`mr-task-icon-${task.id}`);
+ await expect(sidebarBadge).toBeVisible({ timeout: 30_000 });
+
+ // Client-side navigation through the app's own command, so the sidebar
+ // instance (and its already-satisfied fetchedRef) stays mounted.
+ await testPage.keyboard.press("ControlOrMeta+k");
+ await testPage.getByRole("option", { name: "Switch to List View" }).click();
+ await expect(testPage).toHaveURL(/\/tasks/);
+ await expect(testPage.getByTestId("tasks-list")).toBeVisible({ timeout: 30_000 });
+
+ // The regression: TasksPageClient mounting must not empty the slice the
+ // sidebar is still rendering from. Given a few seconds so a wipe that is
+ // merely slow still fails the assertion rather than racing past it.
+ await expect(sidebarBadge).toBeVisible({ timeout: 10_000 });
+ await expect(sidebarBadge).toHaveAttribute("data-mr-count", "1");
+ });
+
+ test("AC11: the /tasks rich list row renders the MR badge and its structured hover", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ // The badge only renders on this row in the rich ("show details") mode —
+ // PrimaryTaskLine is given showContributions={false} otherwise.
+ await apiClient.saveUserSettings({
+ workspace_id: seedData.workspaceId,
+ enable_preview_on_click: false,
+ tasks_list_show_details: true,
+ });
+ await configureGitLab(apiClient, seedData);
+
+ const task = await seedTask(apiClient, seedData, "Tasks list MR badge task");
+ const iid = await seedAndLinkMR(apiClient, seedData, task.id);
+
+ await testPage.goto("/tasks");
+ await testPage.waitForLoadState("networkidle");
+
+ const row = testPage
+ .getByTestId("tasks-list")
+ .getByTestId("tasks-list-row")
+ .filter({ hasText: "Tasks list MR badge task" })
+ .first();
+ await expect(row).toBeVisible({ timeout: 45_000 });
+
+ // AC11: the badge reaches this surface at all. It needs the page's own
+ // useWorkspaceMRs to have hydrated the slice, so this also guards the
+ // fetch that the sidebar-cache fix above un-gated.
+ const badge = row.getByTestId(`mr-task-icon-${task.id}`);
+ await expect(badge).toBeVisible({ timeout: 30_000 });
+ await expect(badge).toHaveAttribute("data-mr-count", "1");
+
+ // AC1/AC24: the same structured summary the board card gets, not a
+ // pipe-delimited string.
+ await badge.hover();
+ const summary = testPage
+ .locator(
+ '[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="mr-task-status-summary"]',
+ )
+ .first();
+ await expect(summary).toBeVisible();
+ await expect(summary.getByTestId("mr-task-status-iid")).toHaveText(`MR !${iid}`);
+ await expect(summary).not.toContainText("|");
+ });
+});
diff --git a/apps/web/e2e/tests/gitlab/mr-task-card-badge.spec.ts b/apps/web/e2e/tests/gitlab/mr-task-card-badge.spec.ts
index 53043609f3..9e51614f9d 100644
--- a/apps/web/e2e/tests/gitlab/mr-task-card-badge.spec.ts
+++ b/apps/web/e2e/tests/gitlab/mr-task-card-badge.spec.ts
@@ -3,6 +3,18 @@ import { KanbanPage } from "../../pages/kanban-page";
import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
+import type { Page } from "@playwright/test";
+
+// Mirrors visibleTaskPRSummary in e2e/tests/pr/pr-status-badge.spec.ts:
+// scoped to the visible (non-closed) tooltip portal so a leftover
+// force-mounted measurement copy never matches.
+function visibleMRTaskStatusSummary(page: Page) {
+ return page
+ .locator(
+ '[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="mr-task-status-summary"]',
+ )
+ .first();
+}
let nextIID = 400;
@@ -229,8 +241,73 @@ test.describe("GitLab MR badge on the Kanban card", () => {
});
expect(order).toBe("pr-then-mr");
- // AC37: state is never colour-only — the tooltip names it explicitly.
+ // AC37: state is never colour-only — the structured summary names it
+ // explicitly via a State row (AC4/AC24: "Merged", not the raw enum value).
await mrIcon.hover();
- await expect(testPage.getByRole("tooltip")).toContainText("merged");
+ const summary = visibleMRTaskStatusSummary(testPage);
+ await expect(summary.getByTestId("mr-task-status-state")).toContainText("Merged");
+ });
+
+ test("AC1/AC7/AC20/AC24: hovering and focusing the badge render the structured MR summary, and Escape dismisses it", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ await ensureGitLabConfigured(apiClient, seedData);
+ const iid = nextMRIID();
+ const mrTitle = "Make the MR badge read like the GitHub one";
+ await seedMR(apiClient, seedData.workspaceId, iid, { title: mrTitle, state: "open" });
+ await apiClient.mockGitLabAddPipelines(seedData.workspaceId, GITLAB_PROJECT, [
+ {
+ id: iid + 50_000,
+ iid: 1,
+ status: "success",
+ source: "push",
+ ref: `feature/badge-${iid}`,
+ sha: `sha-${iid}`,
+ web_url: "",
+ jobs_total: 2,
+ jobs_passing: 2,
+ },
+ ]);
+ const task = await seedBoardTask(apiClient, seedData, "Structured MR summary task");
+ await linkMR(apiClient, seedData, task.id, iid);
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCard(task.id);
+ await expect(card).toBeVisible({ timeout: 45_000 });
+
+ const icon = card.getByTestId(`mr-task-icon-${task.id}`);
+ await expect(icon).toBeVisible({ timeout: 15_000 });
+ await expect(icon).toHaveAttribute("role", "img");
+
+ await icon.hover();
+ const summary = visibleMRTaskStatusSummary(testPage);
+ await expect(summary).toBeVisible();
+ await expect(summary.getByTestId("mr-task-status-iid")).toHaveText(`MR !${iid}`);
+ await expect(summary.getByTestId("mr-task-status-title")).toHaveText(mrTitle);
+ const ciRow = summary.getByTestId("mr-task-status-ci");
+ await expect(ciRow).toContainText("CI");
+ await expect(ciRow).toContainText("Passed");
+ // No pipe-delimited string anywhere in the summary (AC1).
+ await expect(summary).not.toContainText("|");
+
+ // AC20: no document-level horizontal overflow while the summary is open.
+ expect(
+ await testPage.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+
+ // AC7: keyboard focus opens the same summary, and Escape dismisses it.
+ await testPage.mouse.move(0, 0);
+ await icon.focus();
+ await expect(icon).toBeFocused();
+ const focusedSummary = visibleMRTaskStatusSummary(testPage);
+ await expect(focusedSummary).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await expect(focusedSummary).toBeHidden();
});
});
diff --git a/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts b/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts
new file mode 100644
index 0000000000..8407dfd359
--- /dev/null
+++ b/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+import { SessionPage } from "../../pages/session-page";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+
+test.describe("mobile task title hover on the Kanban card", () => {
+ test("AC18/AC20: no hover card mounts on a coarse pointer, and tapping the title navigates as before", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ await apiClient.saveUserSettings({
+ workspace_id: seedData.workspaceId,
+ enable_preview_on_click: false,
+ });
+ // No agent, deliberately — see the desktop title-hover spec's rationale.
+ const task = await apiClient.createTask(seedData.workspaceId, "Mobile title hover parent", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const childTitle = "Mobile child title that stays available in the task switcher";
+ await apiClient.createTask(seedData.workspaceId, childTitle, {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ parent_id: task.id,
+ workspace_mode: "inherit_parent",
+ });
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCard(task.id);
+ await expect(card).toBeVisible({ timeout: 45_000 });
+
+ const title = card.getByTestId("task-card-title");
+ await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
+
+ // Tapping the title performs the surface's existing navigation (opening
+ // the task), not a hover disclosure — there is no separate touch target.
+ await title.tap();
+ await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`));
+ await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await testPage.getByTestId("mobile-session-menu").tap();
+ const taskSwitcher = testPage.getByRole("dialog", { name: "Tasks" });
+ await expect(taskSwitcher.getByText(childTitle)).toBeVisible({ timeout: 15_000 });
+
+ await assertNoDocumentHorizontalOverflow(testPage, "mobile Kanban board after tapping title");
+ });
+});
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
new file mode 100644
index 0000000000..bb7770de14
--- /dev/null
+++ b/apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts
@@ -0,0 +1,140 @@
+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";
+
+/**
+ * Seeds a parent task with two subtasks. No agent is started (mirrors the
+ * MR/PR badge specs' rationale): an auto-started session's on_turn_complete
+ * would move a card mid-test and detach the hover target from under the
+ * assertion.
+ */
+async function seedParentWithSubtasks(apiClient: ApiClient, seedData: SeedData, title: string) {
+ await apiClient.saveUserSettings({
+ workspace_id: seedData.workspaceId,
+ enable_preview_on_click: false,
+ });
+ const parent = await apiClient.createTask(seedData.workspaceId, title, {
+ description: "Title hover fixture parent",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const childOne = await apiClient.createTask(
+ seedData.workspaceId,
+ "First subtask with a long enough title to wrap",
+ {
+ 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, "Second subtask", {
+ 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 Kanban card", () => {
+ test("AC12/AC13/AC14: hovering the title shows the full bold title and subtask rows, and clicking a row navigates", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const longTitle = "A parent task title long enough to truncate on card";
+ const { parent, childOne, childTwo } = await seedParentWithSubtasks(
+ apiClient,
+ seedData,
+ longTitle,
+ );
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCard(parent.id);
+ await expect(card).toBeVisible({ timeout: 45_000 });
+
+ const title = card.getByTestId("task-card-title");
+ await title.hover();
+
+ const hoverCard = testPage.getByTestId("task-title-hover-card");
+ await expect(hoverCard).toBeVisible();
+ // AC12: the full, untruncated title renders (the card's own is
+ // line-clamped, so this line only passes if the hover card itself
+ // carries the un-clamped copy).
+ await expect(hoverCard).toContainText(longTitle);
+
+ const rowOne = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
+ const rowTwo = hoverCard.getByTestId(`task-subtask-row-${childTwo.id}`);
+ await expect(rowOne).toBeVisible();
+ await expect(rowTwo).toBeVisible();
+ await expect(rowOne).toContainText("First subtask");
+ await expect(rowTwo).toContainText("Second subtask");
+
+ // AC20: no document-level horizontal overflow while the hover card is open.
+ expect(
+ await testPage.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+
+ // AC14: clicking a subtask row navigates without triggering the parent
+ // card's own click handler (which would open the parent task instead).
+ await rowOne.click();
+ 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 ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const task = await apiClient.createTask(seedData.workspaceId, "Childless title hover task", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCard(task.id);
+ 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);
+ });
+
+ test("the open hover card does not swallow the click that opens the parent task", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ // The card is an interactive HoverCard, not a pointer-events-none tooltip,
+ // so it really does sit over the board once open. Opening the parent task
+ // by clicking its card is the board's primary action and must survive.
+ const { parent } = await seedParentWithSubtasks(apiClient, seedData, "Clickable parent card");
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+ const card = kanban.taskCard(parent.id);
+ await expect(card).toBeVisible({ timeout: 45_000 });
+
+ const title = card.getByTestId("task-card-title");
+ await title.hover();
+ await expect(testPage.getByTestId("task-title-hover-card")).toBeVisible();
+
+ await title.click();
+ await expect(testPage).toHaveURL(new RegExp(`/t/${parent.id}`));
+ });
+});
diff --git a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts
index acc077a53e..dbab8d8be7 100644
--- a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts
+++ b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts
@@ -313,7 +313,10 @@ test.describe("saved Dockview layouts", () => {
await expect(testPage.getByText("parent session")).toBeVisible({ timeout: 30_000 });
const sidebar = testPage.getByTestId("app-sidebar");
- await sidebar.getByRole("button", { name: /Shared environment layout child/ }).click();
+ // Not getByRole("button", {name}): the row's title now also renders its
+ // own nested button (the keyboard-operable title-preview trigger) with
+ // the same accessible name, so a name-based query is ambiguous.
+ await sidebar.locator(`[data-task-row-id="${child.id}"]`).click();
await expect(testPage).toHaveURL(new RegExp(`/t/${child.id}(?:\\?|$)`), { timeout: 10_000 });
await expect
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 e69a8068f9..9c619c0acb 100644
--- a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts
+++ b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts
@@ -435,6 +435,9 @@ 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");
await testPage.keyboard.press("Tab");
await expect(icon).toBeFocused();
const focusedSummary = visibleTaskPRSummary(testPage);
diff --git a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts
index 94ef6278b0..32b9079bc2 100644
--- a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts
+++ b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts
@@ -4,9 +4,12 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { ApiClient } from "../../helpers/api-client";
+import { waitForHttp } from "../../helpers/causal-waits";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
+const WORKSPACE_SOURCES_PATH = /^\/api\/v1\/tasks\/[^/]+\/workspace-sources$/;
+
async function chooseDirectory(
page: Page,
trigger: Locator,
@@ -48,6 +51,15 @@ function activeFileTab(page: Page, filename: string) {
return page.locator(".dv-tab.dv-active-tab", { hasText: filename }).first();
}
+async function submitWorkspaceSources(page: Page, submit: Locator) {
+ const responsePromise = waitForHttp(page, "POST", WORKSPACE_SOURCES_PATH);
+
+ await submit.click();
+ const response = await responsePromise;
+ expect(response.ok()).toBe(true);
+ await response.finished();
+}
+
async function waitForWorkspaceReady(
apiClient: ApiClient,
taskId: string,
@@ -180,8 +192,8 @@ test.describe("Attach local workspace sources", () => {
repositoryPath,
);
await repositoryRow.getByRole("textbox", { name: "Base branch" }).fill("main");
- await submit.click();
- await expect(dialog).not.toBeVisible({ timeout: 30_000 });
+ await submitWorkspaceSources(testPage, submit);
+ await expect(dialog).not.toBeVisible();
await expect(
session.files
.getByTestId("file-tree-node")
@@ -205,8 +217,8 @@ test.describe("Attach local workspace sources", () => {
await prCapture.screenshot("workspace-actions-mixed-sources", {
caption: "Desktop Add to workspace dialog with a local folder configured",
});
- await submit.click();
- await expect(dialog).not.toBeVisible({ timeout: 30_000 });
+ await submitWorkspaceSources(testPage, submit);
+ await expect(dialog).not.toBeVisible();
await expect(
session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }),
diff --git a/apps/web/e2e/tests/task/file-tree-rename.spec.ts b/apps/web/e2e/tests/task/file-tree-rename.spec.ts
index 53feff6fa9..7dad8d407e 100644
--- a/apps/web/e2e/tests/task/file-tree-rename.spec.ts
+++ b/apps/web/e2e/tests/task/file-tree-rename.spec.ts
@@ -14,8 +14,8 @@ import { dwell } from "../../helpers/causal-waits";
// Inline rename lives in file-context-menu.tsx (useFileRename + TreeNodeName).
// Entry points (today, in product code):
// - Right-click -> "Rename" menu item
-// - The input is given focus 150ms after isRenaming=true and blur-commit is
-// gated by a 400ms ref so the initial focus race doesn't fire onBlur.
+// - The input is focused immediately after isRenaming=true, while blur-commit is
+// gated by a 400ms ref so the initial focus handoff does not fire onBlur.
// Commit on Enter, cancel on Escape, commit on blur.
// We test the user-visible flow only (no direct DOM hacks), so the 400ms
// blur gate is exercised implicitly.
@@ -48,9 +48,7 @@ async function startRenameViaContextMenu(testPage: Page, node: ReturnType {
+ test("AC12/AC13/AC14: the /tasks rich list row shows the full title and clickable subtask rows", 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 })
+ .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).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);
+ });
+
+ test("AC16: an archived subtask drops out of the hover card, the live one stays", 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 testPage.goto("/tasks");
+ await testPage.waitForLoadState("networkidle");
+
+ const sidebarRow = testPage
+ .getByTestId("app-sidebar")
+ .getByTestId("sidebar-task-item")
+ .filter({ hasText: PARENT_TITLE })
+ .first();
+ 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);
+ });
+});
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index c57a8456dc..828288ffe1 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -1422,6 +1422,7 @@ export const i18nGuardFiles = [
"components/github/pr-status-chip.tsx",
"components/github/pr-topbar-button.tsx",
"components/gitlab/mr-commits-section.tsx",
+ "components/gitlab/mr-task-status-summary.tsx",
"components/gitlab/mr-detail-panel.tsx",
"components/gitlab/mr-discussions-section.tsx",
"components/gitlab/mr-files-section.tsx",
@@ -1584,6 +1585,9 @@ export const i18nGuardFiles = [
"components/kanban-board.tsx",
"components/kanban-card-content.tsx",
"components/kanban-card-menu-items.tsx",
+ // Extracted out of kanban-card-content.tsx, which is guarded: without its own
+ // entry the card title markup would silently leave whole-file guard coverage.
+ "components/kanban-card-title.tsx",
"components/kanban-column.tsx",
"components/kanban-display-dropdown.tsx",
"components/kanban-with-preview.tsx",
diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
index 1b0304afc3..c9d63d52b7 100644
--- a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
+++ b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
@@ -89,6 +89,29 @@ function workspaceProbe(workspaceId: string) {
});
}
+// Simulates AppSidebar and /tasks mounting separate hook instances for the
+// same workspace. Both instances share one store and one active request.
+function DualWorkspaceMRInstance() {
+ useWorkspaceMRs("ws-1");
+ return null;
+}
+function DualWorkspaceMRHarness({ mountSecond }: { mountSecond: boolean }) {
+ const mrs = useAppStore((state) => state.taskMRs.byWorkspaceId["ws-1"] ?? EMPTY_TASK_MRS);
+ return createElement(
+ "div",
+ null,
+ createElement(DualWorkspaceMRInstance, { key: "first" }),
+ mountSecond ? createElement(DualWorkspaceMRInstance, { key: "second" }) : null,
+ createElement("output", { "data-testid": WORKSPACE_MRS_TEST_ID }, JSON.stringify(mrs)),
+ );
+}
+function dualWorkspaceMRTree(mountSecond: boolean) {
+ return createElement(StateProvider, {
+ initialState: { workspaces: { items: [], activeId: "ws-1" } },
+ children: createElement(DualWorkspaceMRHarness, { mountSecond }),
+ });
+}
+
describe("useWorkspaceMRs", () => {
beforeEach(() => {
listWorkspaceTaskMRsMock.mockReset();
@@ -209,6 +232,69 @@ describe("useWorkspaceMRs", () => {
});
});
+describe("useWorkspaceMRs cache failures", () => {
+ beforeEach(() => {
+ listWorkspaceTaskMRsMock.mockReset();
+ });
+
+ it("removes cached workspace data when its refresh fails", async () => {
+ const staleMR = makeMR({ id: "stale", task_id: "task-1" });
+ listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom"));
+
+ const { result } = renderHook(
+ () => {
+ useWorkspaceMRs("ws-1");
+ return useAppStore((state) => state.taskMRs.byWorkspaceId["ws-1"] ?? EMPTY_TASK_MRS);
+ },
+ {
+ wrapper: ({ children }) =>
+ createElement(StateProvider, {
+ initialState: {
+ workspaces: { items: [], activeId: "ws-1" },
+ taskMRs: { byWorkspaceId: { "ws-1": { "task-1": [staleMR] } } },
+ },
+ children,
+ }),
+ },
+ );
+
+ expect(result.current["task-1"]).toEqual([staleMR]);
+ await waitFor(() => expect(result.current).toEqual({}));
+ });
+});
+
+describe("useWorkspaceMRs, co-mounted instances", () => {
+ beforeEach(() => {
+ listWorkspaceTaskMRsMock.mockReset();
+ });
+
+ it("shares an in-flight request but permits a later refresh", async () => {
+ const mr = makeMR({ task_id: "task-1" });
+ let resolveFirst: (v: { task_mrs: Record }) => void = () => {};
+ const firstPromise = new Promise<{ task_mrs: Record }>((resolve) => {
+ resolveFirst = resolve;
+ });
+ listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise);
+
+ const view = render(dualWorkspaceMRTree(true));
+ await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
+
+ await act(async () => {
+ resolveFirst({ task_mrs: { "task-1": [mr] } });
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId(WORKSPACE_MRS_TEST_ID).textContent).toEqual(
+ JSON.stringify({ "task-1": [mr] }),
+ ),
+ );
+
+ view.rerender(dualWorkspaceMRTree(false));
+ listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } });
+ view.rerender(dualWorkspaceMRTree(true));
+ await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2));
+ });
+});
+
// AC13: pins the existing full-clear behaviour that AC12 requires callers
// (tasks-page-client.tsx) to route around by never invoking the hook with
// null while a workspace is active. Kept to a single renderHook tree so
diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.ts b/apps/web/hooks/domains/gitlab/use-task-mr.ts
index eb58703ade..7b8873617e 100644
--- a/apps/web/hooks/domains/gitlab/use-task-mr.ts
+++ b/apps/web/hooks/domains/gitlab/use-task-mr.ts
@@ -8,11 +8,28 @@ import { useToast } from "@/components/toast-provider";
import type { TaskMR } from "@/lib/types/gitlab";
import { useGitLabStatus } from "./use-gitlab-status";
+type WorkspaceMRResponse = Awaited>;
+const workspaceMRRequests = new Map>();
+
+function acquireWorkspaceMRRequest(workspaceId: string) {
+ const activeRequest = workspaceMRRequests.get(workspaceId);
+ if (activeRequest) return activeRequest;
+
+ const request = listWorkspaceTaskMRs(workspaceId, { cache: "no-store" });
+ workspaceMRRequests.set(workspaceId, request);
+ const release = () => {
+ if (workspaceMRRequests.get(workspaceId) === request) {
+ workspaceMRRequests.delete(workspaceId);
+ }
+ };
+ void request.then(release, release);
+ return request;
+}
+
/**
- * Hydrate the gitlab task-MRs slice for a workspace. Fetches once per
- * workspaceId switch and clears the cache on null. Mirrors useWorkspacePRs
- * for GitHub but stays minimal (no WS subscription yet — that lands with
- * the poller in a follow-up phase).
+ * Hydrate the GitLab task-MRs slice for a workspace. Co-mounted consumers
+ * share one in-flight request. A later mount can start a fresh request. The
+ * hook clears the cache when the workspace becomes null or a refresh fails.
*/
export function useWorkspaceMRs(workspaceId: string | null) {
const setTaskMRs = useAppStore((state) => state.setTaskMRs);
@@ -33,14 +50,17 @@ export function useWorkspaceMRs(workspaceId: string | null) {
if (fetchedRef.current === workspaceId) return;
const requestId = ++requestRef.current;
fetchedRef.current = workspaceId;
- resetTaskMRs(workspaceId);
- listWorkspaceTaskMRs(workspaceId, { cache: "no-store" })
+ const request = acquireWorkspaceMRRequest(workspaceId);
+ // Keep the current data visible until the shared request succeeds. A
+ // failure clears that workspace below, so stale data cannot remain.
+ request
.then((response) => {
if (requestRef.current !== requestId) return;
setTaskMRs(workspaceId, response?.task_mrs ?? {});
})
.catch(() => {
if (requestRef.current === requestId) {
+ resetTaskMRs(workspaceId);
fetchedRef.current = null; // allow retry on failure
}
});
diff --git a/apps/web/hooks/domains/kanban/use-task-subtasks.test.tsx b/apps/web/hooks/domains/kanban/use-task-subtasks.test.tsx
new file mode 100644
index 0000000000..be28dd49cb
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-task-subtasks.test.tsx
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { StateProvider } from "@/components/state-provider";
+import type { KanbanState } from "@/lib/state/slices/kanban/types";
+import { useTaskSubtasks } from "./use-task-subtasks";
+
+function makeTask(overrides: Partial): KanbanState["tasks"][number] {
+ return {
+ id: "id",
+ workflowId: "wf-1",
+ workflowStepId: "step-1",
+ title: "Task",
+ position: 0,
+ ...overrides,
+ };
+}
+
+function renderWithState(initialState: Parameters[0]["initialState"]) {
+ return renderHook(() => useTaskSubtasks("parent-1"), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+}
+
+describe("useTaskSubtasks", () => {
+ it("returns only direct children of the given task", () => {
+ const { result } = renderWithState({
+ kanban: {
+ workflowId: "wf-1",
+ steps: [],
+ tasks: [
+ makeTask({ id: "parent-1", title: "Parent" }),
+ makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
+ makeTask({ id: "child-2", title: "Child 2", parentTaskId: "parent-1", position: 2 }),
+ makeTask({ id: "other", title: "Unrelated", parentTaskId: "other-parent" }),
+ ],
+ },
+ });
+ expect(result.current.map((t) => t.id)).toEqual(["child-1", "child-2"]);
+ });
+
+ it("excludes grandchildren", () => {
+ const { result } = renderWithState({
+ kanban: {
+ workflowId: "wf-1",
+ steps: [],
+ tasks: [
+ makeTask({ id: "parent-1", title: "Parent" }),
+ makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1", position: 1 }),
+ makeTask({
+ id: "grandchild-1",
+ title: "Grandchild",
+ parentTaskId: "child-1",
+ position: 1,
+ }),
+ ],
+ },
+ });
+ expect(result.current.map((t) => t.id)).toEqual(["child-1"]);
+ });
+
+ it("includes cross-workflow children from kanbanMulti snapshots, de-duplicated against the active board", () => {
+ const { result } = renderWithState({
+ kanban: {
+ workflowId: "wf-1",
+ steps: [],
+ tasks: [
+ makeTask({ id: "parent-1", title: "Parent" }),
+ makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
+ ],
+ },
+ kanbanMulti: {
+ isLoading: false,
+ snapshots: {
+ "wf-2": {
+ workflowId: "wf-2",
+ workflowName: "Other workflow",
+ steps: [],
+ tasks: [
+ // Duplicate of the active-board child-1: must not appear twice.
+ makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
+ makeTask({
+ id: "child-3",
+ title: "Cross-workflow child",
+ parentTaskId: "parent-1",
+ position: 2,
+ }),
+ ],
+ },
+ },
+ },
+ });
+ expect(result.current.map((t) => t.id)).toEqual(["child-1", "child-3"]);
+ });
+});
+
+describe("useTaskSubtasks — ordering and stability", () => {
+ it("orders by position then createdAt", () => {
+ const { result } = renderWithState({
+ kanban: {
+ workflowId: "wf-1",
+ steps: [],
+ tasks: [
+ makeTask({ id: "parent-1", title: "Parent" }),
+ makeTask({
+ id: "b",
+ title: "B",
+ parentTaskId: "parent-1",
+ position: 1,
+ createdAt: "2026-01-02T00:00:00Z",
+ }),
+ makeTask({
+ id: "a",
+ title: "A",
+ parentTaskId: "parent-1",
+ position: 1,
+ createdAt: "2026-01-01T00:00:00Z",
+ }),
+ ],
+ },
+ });
+ expect(result.current.map((t) => t.id)).toEqual(["a", "b"]);
+ });
+
+ it("returns a stable empty reference across renders when there are no subtasks", () => {
+ const { result, rerender } = renderWithState({
+ kanban: {
+ workflowId: "wf-1",
+ steps: [],
+ tasks: [makeTask({ id: "parent-1", title: "Parent" })],
+ },
+ });
+ const first = result.current;
+ rerender();
+ expect(result.current).toEqual([]);
+ expect(result.current).toBe(first);
+ });
+});
diff --git a/apps/web/hooks/domains/kanban/use-task-subtasks.ts b/apps/web/hooks/domains/kanban/use-task-subtasks.ts
new file mode 100644
index 0000000000..09e37c0f70
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-task-subtasks.ts
@@ -0,0 +1,59 @@
+import { useMemo } from "react";
+import { useAppStore } from "@/components/state-provider";
+import type { KanbanState, KanbanMultiState } from "@/lib/state/slices/kanban/types";
+
+export type TaskSubtask = Pick<
+ KanbanState["tasks"][number],
+ "id" | "title" | "state" | "position" | "createdAt"
+>;
+
+// Stable empty reference so the hook's output stays referentially equal
+// across renders when a task has no subtasks — an unstable array from a
+// selector causes an infinite re-render loop (see hooks/domains/gitlab/use-task-mr.ts).
+const EMPTY_SUBTASKS: TaskSubtask[] = [];
+
+function compareSubtasks(a: TaskSubtask, b: TaskSubtask): number {
+ const positionDelta = (a.position ?? 0) - (b.position ?? 0);
+ if (positionDelta !== 0) return positionDelta;
+ return (a.createdAt ?? "").localeCompare(b.createdAt ?? "");
+}
+
+function computeSubtasks(
+ tasks: KanbanState["tasks"],
+ snapshots: KanbanMultiState["snapshots"],
+ taskId: string,
+): TaskSubtask[] {
+ const seen = new Map();
+ for (const task of tasks) {
+ if (task.parentTaskId === taskId) seen.set(task.id, task);
+ }
+ for (const snapshot of Object.values(snapshots)) {
+ for (const task of snapshot.tasks) {
+ if (task.parentTaskId === taskId && !seen.has(task.id)) seen.set(task.id, task);
+ }
+ }
+ if (seen.size === 0) return EMPTY_SUBTASKS;
+ return Array.from(seen.values()).sort(compareSubtasks);
+}
+
+/**
+ * Direct children of `taskId`, sourced from the active board (state.kanban)
+ * plus every loaded cross-workflow snapshot (state.kanbanMulti), so a task
+ * whose subtasks live on a different workflow's board still shows them.
+ * Mirrors separateSubtasks (lib/sidebar/apply-view.ts) rather than inventing
+ * a second grouping rule. De-duplicated by id, ordered by position then
+ * createdAt.
+ *
+ * `tasks` and `snapshots` are read as their own selectors (rather than
+ * filtering inside one selector) so the derived array is memoized against
+ * their stable store references — filtering inline would return a fresh
+ * array on every store update and re-render the caller unnecessarily.
+ */
+export function useTaskSubtasks(taskId: string | null): TaskSubtask[] {
+ const tasks = useAppStore((state) => state.kanban.tasks);
+ const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
+ return useMemo(
+ () => (taskId ? computeSubtasks(tasks, snapshots, taskId) : EMPTY_SUBTASKS),
+ [tasks, snapshots, taskId],
+ );
+}
diff --git a/apps/web/src/locales/en/gitlab.json b/apps/web/src/locales/en/gitlab.json
index d4ceb7305b..d8c8ec0157 100644
--- a/apps/web/src/locales/en/gitlab.json
+++ b/apps/web/src/locales/en/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} unresolved comments",
"mrPopoverUpdatedJustNow": "updated just now",
"mrPopoverUpdatedAgo": "updated {{elapsed}} ago",
- "mrTooltipState": "State: {{state}}",
- "mrTooltipReview": "Review: {{state}}",
- "mrTooltipPipeline": "Pipeline: {{state}}",
- "mrTooltipReadyToMerge": "Ready to merge",
+ "mrTaskStatusState": "State",
+ "mrTaskStatusReview": "Review",
+ "mrTaskStatusCi": "CI",
+ "mrTaskStatusMerge": "Merge",
+ "mrTaskStatusIid": "MR !{{iid}}",
+ "mergeRequestStatus": "Merge request !{{iid}} status",
+ "mergeRequestStatuses_one": "{{count}} merge request status",
+ "mergeRequestStatuses_other": "{{count}} merge request statuses",
+ "merged": "Merged",
+ "closed": "Closed",
+ "approved": "Approved",
+ "awaitingApproval": "Awaiting approval",
+ "passed": "Passed",
+ "failed": "Failed",
+ "inProgress": "In progress",
+ "readyToMerge": "Ready to merge",
+ "mergeable": "Mergeable",
+ "unresolvedDiscussions": "Unresolved discussions",
"mrChipAriaLabelSingle": "Merge request !{{mriid}}, {{status}}",
"mrChipAriaLabelMulti_one": "{{count}} merge request, {{status}}",
"mrChipAriaLabelMulti_other": "{{count}} merge requests, {{status}}",
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index 619a8f0a35..18f5f54c91 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -1420,6 +1420,8 @@
"submitUnavailable": "Submit unavailable",
"submitting": "Submitting…",
"subtask": "Subtask",
+ "subtasksHeading_one": "{{count}} subtask",
+ "subtasksHeading_other": "{{count}} subtasks",
"subtasksBecomeRootTasksUnlessYou": "Subtasks become root tasks unless you tick this. They may still be in progress.",
"subtasksStayActiveUnlessYouTick": "Subtasks stay active unless you tick this. They may still be in progress.",
"successful": "{{operationName}} successful",
diff --git a/apps/web/src/locales/pseudo/gitlab.json b/apps/web/src/locales/pseudo/gitlab.json
index 73e9c9311d..0f02e9aaad 100644
--- a/apps/web/src/locales/pseudo/gitlab.json
+++ b/apps/web/src/locales/pseudo/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} ũńŕēśōĺvēď ćōḿḿēńţś",
"mrPopoverUpdatedJustNow": "ũƥďàţēď ĵũśţ ńōŵ",
"mrPopoverUpdatedAgo": "ũƥďàţēď {{elapsed}} àĝō",
- "mrTooltipState": "Śţàţē: {{state}}",
- "mrTooltipReview": "Ŕēvĩēŵ: {{state}}",
- "mrTooltipPipeline": "Ƥĩƥēĺĩńē: {{state}}",
- "mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
+ "mrTaskStatusState": "Śţàţē",
+ "mrTaskStatusReview": "Ŕēvĩēŵ",
+ "mrTaskStatusCi": "ĆĨ",
+ "mrTaskStatusMerge": "Ḿēŕĝē",
+ "mrTaskStatusIid": "ḾŔ !{{iid}}",
+ "mergeRequestStatus": "Ḿēŕĝē ŕēqũēśţ !{{iid}} śţàţũś",
+ "mergeRequestStatuses_one": "{{count}} ḿēŕĝē ŕēqũēśţ śţàţũś",
+ "mergeRequestStatuses_other": "{{count}} ḿēŕĝē ŕēqũēśţ śţàţũśēś",
+ "merged": "Ḿēŕĝēď",
+ "closed": "Ćĺōśēď",
+ "approved": "Àƥƥŕōvēď",
+ "awaitingApproval": "Àŵàĩţĩńĝ àƥƥŕōvàĺ",
+ "passed": "Ƥàśśēď",
+ "failed": "Ƒàĩĺēď",
+ "inProgress": "Ĩń ƥŕōĝŕēśś",
+ "readyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
+ "mergeable": "Ḿēŕĝēàƀĺē",
+ "unresolvedDiscussions": "Ũńŕēśōĺvēď ďĩśćũśśĩōńś",
"mrChipAriaLabelSingle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}, {{status}}",
"mrChipAriaLabelMulti_one": "{{count}} ḿēŕĝē ŕēqũēśţ, {{status}}",
"mrChipAriaLabelMulti_other": "{{count}} ḿēŕĝē ŕēqũēśţś, {{status}}",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index bc3a882341..b1a97e73e1 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -1420,6 +1420,8 @@
"submitUnavailable": "Śũƀḿĩţ ũńàvàĩĺàƀĺē",
"submitting": "Śũƀḿĩţţĩńĝ…",
"subtask": "Śũƀţàśķ",
+ "subtasksHeading_one": "{{count}} śũƀţàśķ",
+ "subtasksHeading_other": "{{count}} śũƀţàśķś",
"subtasksBecomeRootTasksUnlessYou": "Śũƀţàśķś ƀēćōḿē ŕōōţ ţàśķś ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
"subtasksStayActiveUnlessYouTick": "Śũƀţàśķś śţàŷ àćţĩvē ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
"successful": "{{operationName}} śũććēśśƒũĺ",
diff --git a/apps/web/src/locales/pt-pt/gitlab.json b/apps/web/src/locales/pt-pt/gitlab.json
index 30d43853d6..3e6640fb15 100644
--- a/apps/web/src/locales/pt-pt/gitlab.json
+++ b/apps/web/src/locales/pt-pt/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} comentários por resolver",
"mrPopoverUpdatedJustNow": "atualizado agora mesmo",
"mrPopoverUpdatedAgo": "atualizado há {{elapsed}}",
- "mrTooltipState": "Estado: {{state}}",
- "mrTooltipReview": "Revisão: {{state}}",
- "mrTooltipPipeline": "Pipeline: {{state}}",
- "mrTooltipReadyToMerge": "Pronto para merge",
+ "mrTaskStatusState": "Estado",
+ "mrTaskStatusReview": "Revisão",
+ "mrTaskStatusCi": "CI",
+ "mrTaskStatusMerge": "Integração",
+ "mrTaskStatusIid": "MR !{{iid}}",
+ "mergeRequestStatus": "Estado do merge request !{{iid}}",
+ "mergeRequestStatuses_one": "{{count}} estado de merge request",
+ "mergeRequestStatuses_other": "{{count}} estados de merge request",
+ "merged": "Integrado",
+ "closed": "Fechado",
+ "approved": "Aprovado",
+ "awaitingApproval": "A aguardar aprovação",
+ "passed": "Passou",
+ "failed": "Falhou",
+ "inProgress": "Em curso",
+ "readyToMerge": "Pronto a integrar",
+ "mergeable": "Integrável",
+ "unresolvedDiscussions": "Discussões por resolver",
"gitlabRejectedAssociation": "O GitLab rejeitou a associação.",
"taskCreatedMrNotLinked": "Tarefa criada, mas o merge request não foi associado",
"taskCreatedMrNotLinkedDescription": "Abra a tarefa e use Associar merge request do GitLab para tentar novamente. {{error}}",
diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json
index e59b79f240..81e032b260 100644
--- a/apps/web/src/locales/pt-pt/task.json
+++ b/apps/web/src/locales/pt-pt/task.json
@@ -1375,6 +1375,8 @@
"submitUnavailable": "Submissão indisponível",
"submitting": "A submeter…",
"subtask": "Subtarefa",
+ "subtasksHeading_one": "{{count}} subtarefa",
+ "subtasksHeading_other": "{{count}} subtarefas",
"subtasksBecomeRootTasksUnlessYou": "As subtarefas passam a tarefas de raiz se não assinalar esta opção. Podem ainda estar em curso.",
"subtasksStayActiveUnlessYouTick": "As subtarefas mantêm-se ativas se não assinalar esta opção. Podem ainda estar em curso.",
"successful": "{{operationName}} bem-sucedida",
diff --git a/apps/web/src/locales/zh-cn/gitlab.json b/apps/web/src/locales/zh-cn/gitlab.json
index d6678aa042..27e262906e 100644
--- a/apps/web/src/locales/zh-cn/gitlab.json
+++ b/apps/web/src/locales/zh-cn/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} 条未解决的评论",
"mrPopoverUpdatedJustNow": "刚刚更新",
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
- "mrTooltipState": "状态:{{state}}",
- "mrTooltipReview": "审查:{{state}}",
- "mrTooltipPipeline": "流水线:{{state}}",
- "mrTooltipReadyToMerge": "可以合并",
+ "mrTaskStatusState": "状态",
+ "mrTaskStatusReview": "审阅",
+ "mrTaskStatusCi": "CI",
+ "mrTaskStatusMerge": "合并",
+ "mrTaskStatusIid": "MR !{{iid}}",
+ "mergeRequestStatus": "合并请求 !{{iid}} 状态",
+ "mergeRequestStatuses_one": "{{count}} 个合并请求状态",
+ "mergeRequestStatuses_other": "{{count}} 个合并请求状态",
+ "merged": "已合并",
+ "closed": "已关闭",
+ "approved": "已批准",
+ "awaitingApproval": "等待批准",
+ "passed": "通过",
+ "failed": "失败",
+ "inProgress": "进行中",
+ "readyToMerge": "可以合并",
+ "mergeable": "可合并",
+ "unresolvedDiscussions": "未解决的讨论",
"mrChipAriaLabelSingle": "合并请求 !{{mriid}},{{status}}",
"mrChipAriaLabelMulti_one": "{{count}} 个合并请求,{{status}}",
"mrChipAriaLabelMulti_other": "{{count}} 个合并请求,{{status}}",
diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json
index 4f0efb5913..ef3ba0e7cf 100644
--- a/apps/web/src/locales/zh-cn/task.json
+++ b/apps/web/src/locales/zh-cn/task.json
@@ -1374,6 +1374,8 @@
"submitUnavailable": "无法提交",
"submitting": "正在提交…",
"subtask": "子任务",
+ "subtasksHeading_one": "{{count}} 个子任务",
+ "subtasksHeading_other": "{{count}} 个子任务",
"subtasksBecomeRootTasksUnlessYou": "除非勾选此项,否则子任务将成为根任务。它们可能仍在进行中。",
"subtasksStayActiveUnlessYouTick": "除非勾选此项,否则子任务保持活动。它们可能仍在进行中。",
"successful": "{{operationName}} 成功",
diff --git a/apps/web/src/locales/zh-hk/gitlab.json b/apps/web/src/locales/zh-hk/gitlab.json
index f320db224c..0ce39512b0 100644
--- a/apps/web/src/locales/zh-hk/gitlab.json
+++ b/apps/web/src/locales/zh-hk/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} 條未解決的評論",
"mrPopoverUpdatedJustNow": "剛剛更新",
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
- "mrTooltipState": "狀態:{{state}}",
- "mrTooltipReview": "審查:{{state}}",
- "mrTooltipPipeline": "流水線:{{state}}",
- "mrTooltipReadyToMerge": "可以合併",
+ "mrTaskStatusState": "狀態",
+ "mrTaskStatusReview": "審閱",
+ "mrTaskStatusCi": "CI",
+ "mrTaskStatusMerge": "合併",
+ "mrTaskStatusIid": "MR !{{iid}}",
+ "mergeRequestStatus": "合併請求 !{{iid}} 狀態",
+ "mergeRequestStatuses_one": "{{count}} 個合併請求狀態",
+ "mergeRequestStatuses_other": "{{count}} 個合併請求狀態",
+ "merged": "已合併",
+ "closed": "已關閉",
+ "approved": "已批准",
+ "awaitingApproval": "等待批准",
+ "passed": "通過",
+ "failed": "失敗",
+ "inProgress": "進行中",
+ "readyToMerge": "可以合併",
+ "mergeable": "可合併",
+ "unresolvedDiscussions": "未解決的討論",
"mrChipAriaLabelSingle": "合併請求 !{{mriid}},{{status}}",
"mrChipAriaLabelMulti_one": "{{count}} 個合併請求,{{status}}",
"mrChipAriaLabelMulti_other": "{{count}} 個合併請求,{{status}}",
diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json
index 4d277ea478..22ff336f54 100644
--- a/apps/web/src/locales/zh-hk/task.json
+++ b/apps/web/src/locales/zh-hk/task.json
@@ -1374,6 +1374,8 @@
"submitUnavailable": "無法提交",
"submitting": "正在提交…",
"subtask": "子任務",
+ "subtasksHeading_one": "{{count}} 個子任務",
+ "subtasksHeading_other": "{{count}} 個子任務",
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
"successful": "{{operationName}} 成功",
diff --git a/apps/web/src/locales/zh-tw/gitlab.json b/apps/web/src/locales/zh-tw/gitlab.json
index 6189e5e8e9..ee72855915 100644
--- a/apps/web/src/locales/zh-tw/gitlab.json
+++ b/apps/web/src/locales/zh-tw/gitlab.json
@@ -326,10 +326,24 @@
"mrPopoverUnresolvedComments_other": "{{count}} 條未解決的評論",
"mrPopoverUpdatedJustNow": "剛剛更新",
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
- "mrTooltipState": "狀態:{{state}}",
- "mrTooltipReview": "審查:{{state}}",
- "mrTooltipPipeline": "流水線:{{state}}",
- "mrTooltipReadyToMerge": "可以合併",
+ "mrTaskStatusState": "狀態",
+ "mrTaskStatusReview": "審閱",
+ "mrTaskStatusCi": "CI",
+ "mrTaskStatusMerge": "合併",
+ "mrTaskStatusIid": "MR !{{iid}}",
+ "mergeRequestStatus": "合併請求 !{{iid}} 狀態",
+ "mergeRequestStatuses_one": "{{count}} 個合併請求狀態",
+ "mergeRequestStatuses_other": "{{count}} 個合併請求狀態",
+ "merged": "已合併",
+ "closed": "已關閉",
+ "approved": "已批准",
+ "awaitingApproval": "等待批准",
+ "passed": "通過",
+ "failed": "失敗",
+ "inProgress": "進行中",
+ "readyToMerge": "可以合併",
+ "mergeable": "可合併",
+ "unresolvedDiscussions": "未解決的討論",
"mrChipAriaLabelSingle": "合併請求 !{{mriid}},{{status}}",
"mrChipAriaLabelMulti_one": "{{count}} 個合併請求,{{status}}",
"mrChipAriaLabelMulti_other": "{{count}} 個合併請求,{{status}}",
diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json
index 3830358b14..f551327465 100644
--- a/apps/web/src/locales/zh-tw/task.json
+++ b/apps/web/src/locales/zh-tw/task.json
@@ -1374,6 +1374,8 @@
"submitUnavailable": "無法提交",
"submitting": "正在提交…",
"subtask": "子任務",
+ "subtasksHeading_one": "{{count}} 個子任務",
+ "subtasksHeading_other": "{{count}} 個子任務",
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
"successful": "{{operationName}} 成功",
diff --git a/docs/plans/gitlab-mr-task-list-badges/plan.md b/docs/plans/gitlab-mr-task-list-badges/plan.md
index af796d0a88..59f50b7c5e 100644
--- a/docs/plans/gitlab-mr-task-list-badges/plan.md
+++ b/docs/plans/gitlab-mr-task-list-badges/plan.md
@@ -6,6 +6,10 @@ status: draft
# Implementation Plan: GitLab MR Badge on the Sidebar and Tasks-List Rows
+> **Amendment:** The later
+> [rich task title preview plan](../rich-task-title-previews/plan.md) extends
+> this plan. It owns rich badge content and shared hydration behavior.
+
## Overview
`MRTaskIcon` ships today with exactly one call site (the Kanban card). This plan
diff --git a/docs/plans/rich-task-title-previews/plan.md b/docs/plans/rich-task-title-previews/plan.md
new file mode 100644
index 0000000000..f01ad79996
--- /dev/null
+++ b/docs/plans/rich-task-title-previews/plan.md
@@ -0,0 +1,71 @@
+---
+spec: docs/specs/tasks/rich-task-title-previews.md
+created: 2026-08-18
+status: complete
+---
+
+# Implementation plan: Rich task title previews
+
+## 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.
+
+## Backend
+
+None. The feature uses existing task and contribution APIs.
+
+## Frontend
+
+### Preview and task hierarchy
+
+`TaskTitleHoverCard` owns pointer and keyboard preview behavior.
+`useTaskSubtasks` selects direct active subtasks. `TaskSubtaskRow` owns subtask
+navigation and prevents its activation from reaching the parent row.
+
+### Contribution status
+
+`ChangeRequestTaskStatusSummary` owns the generic status-summary layout.
+GitHub and GitLab components derive provider-specific rows and presentation data.
+
+### GitLab state
+
+`useWorkspaceMRs` shares one in-flight workspace request between mounted consumers.
+It permits later refreshes and clears the workspace after failure.
+
+## Tests
+
+- **What:** pointer and keyboard preview behavior, subtask limits, and navigation isolation.
+ **File:** `apps/web/components/task/task-title-hover-card.test.tsx`.
+ **How:** component tests with a shared state provider.
+- **What:** GitLab state derivation, request sharing, and failed-refresh cleanup.
+ **File:** `apps/web/hooks/domains/gitlab/use-task-mr.test.ts` and GitLab summary tests.
+ **How:** hook and pure-function tests with mocked API responses.
+- **What:** provider-neutral status presentation.
+ **File:** GitHub and GitLab status-summary render tests.
+ **How:** render tests with provider-specific presentation data.
+
+## 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`.
+ **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.
+
+## Verification results
+
+- Seven focused Vitest files passed: 130 tests.
+- TypeScript, ESLint, the locale audit, and the i18n ratchet passed.
+- The desktop Chromium flow passed four tests.
+- The mobile Chrome flow passed one test.
+- `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)
diff --git a/docs/plans/rich-task-title-previews/task-01-preview-and-hierarchy.md b/docs/plans/rich-task-title-previews/task-01-preview-and-hierarchy.md
new file mode 100644
index 0000000000..5d94f0b520
--- /dev/null
+++ b/docs/plans/rich-task-title-previews/task-01-preview-and-hierarchy.md
@@ -0,0 +1,21 @@
+---
+id: "01-preview-and-hierarchy"
+title: "Preview and hierarchy"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/tasks/rich-task-title-previews.md"
+---
+
+# Task 01: Preview and hierarchy
+
+- **Acceptance:** Fine-pointer task titles show the reusable preview. Keyboard activation opens it. Subtask activation does not open the parent.
+- **Verification:** `cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx hooks/domains/kanban/use-task-subtasks.test.tsx`.
+- **Files likely touched:** task title, subtask, task-row, Kanban-card, and rich-list components and tests.
+- **Dependencies:** None.
+- **Parallelism:** sequential.
+
+## Results
+
+`cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx hooks/domains/kanban/use-task-subtasks.test.tsx` passed as part of the final focused run. The full focused run passed 130 tests across seven files. A new focus regression failed before the explicit focus fix and passed after it. External side effects: None.
diff --git a/docs/plans/rich-task-title-previews/task-02-contribution-status-and-state.md b/docs/plans/rich-task-title-previews/task-02-contribution-status-and-state.md
new file mode 100644
index 0000000000..232db396f4
--- /dev/null
+++ b/docs/plans/rich-task-title-previews/task-02-contribution-status-and-state.md
@@ -0,0 +1,21 @@
+---
+id: "02-contribution-status-and-state"
+title: "Contribution status and state"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/tasks/rich-task-title-previews.md"
+---
+
+# Task 02: Contribution status and state
+
+- **Acceptance:** GitHub and GitLab use one generic summary renderer. Co-mounted GitLab consumers share one in-flight request. Later mounts can refresh, and failed refreshes clear stale workspace data.
+- **Verification:** `cd apps && pnpm --filter @kandev/web test -- --run hooks/domains/gitlab/use-task-mr.test.ts components/gitlab/mr-task-status-summary.test.ts components/gitlab/mr-task-icon.render.test.tsx components/github/pr-task-icon.test.ts components/github/pr-task-icon.render.test.tsx`.
+- **Files likely touched:** change-request summary components, GitLab adapters, hydration hook, and focused tests.
+- **Dependencies:** None.
+- **Parallelism:** sequential because the renderer and hook share GitLab consumers.
+
+## Results
+
+The final seven-file Vitest command passed 130 tests. It includes 21 GitLab hook tests and the GitHub and GitLab renderer tests. `cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check && pnpm run i18n:ratchet` passed. External side effects: None.
diff --git a/docs/plans/rich-task-title-previews/task-03-end-to-end-verification.md b/docs/plans/rich-task-title-previews/task-03-end-to-end-verification.md
new file mode 100644
index 0000000000..1429c07657
--- /dev/null
+++ b/docs/plans/rich-task-title-previews/task-03-end-to-end-verification.md
@@ -0,0 +1,21 @@
+---
+id: "03-end-to-end-verification"
+title: "End-to-end verification"
+status: done
+wave: 2
+depends_on: ["01-preview-and-hierarchy", "02-contribution-status-and-state"]
+plan: "plan.md"
+spec: "../../specs/tasks/rich-task-title-previews.md"
+---
+
+# Task 03: End-to-end verification
+
+- **Acceptance:** Desktop pointer and keyboard flows work on real task data. Mobile direct navigation and task-tree access work on a coarse pointer.
+- **Verification:** `cd apps/web && pnpm e2e:run tests/task/task-title-hover-surfaces.spec.ts && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts`.
+- **Files likely touched:** the desktop and mobile task-title Playwright specifications.
+- **Dependencies:** Tasks 01 and 02.
+- **Parallelism:** sequential.
+
+## Results
+
+`cd apps/web && pnpm e2e:run tests/task/task-title-hover-surfaces.spec.ts` first found the missing child-link focus. After the fix, all four Chromium tests passed. `cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts` passed one test. The managed runner removed its temporary backends and fixture data. External side effects: local temporary E2E data only.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 8e8ec8f9db..d7c62cc6db 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -74,6 +74,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
| [interrupted-task-indicator](tasks/interrupted-task-indicator.md) | complete |
| [labels](tasks/labels.md) | shipped |
| [title-length-limit](tasks/title-length-limit.md) | complete |
+| [rich-task-title-previews](tasks/rich-task-title-previews.md) | building |
| [active clarification lifecycle](clarification-active-lifecycle/spec.md) | approved |
| [model-unification](tasks/model-unification.md) | draft |
| [run-scheduling](tasks/run-scheduling.md) | building |
diff --git a/docs/specs/gitlab-mr-task-list-badges/spec.md b/docs/specs/gitlab-mr-task-list-badges/spec.md
index 00d378c56c..9b46e69d8b 100644
--- a/docs/specs/gitlab-mr-task-list-badges/spec.md
+++ b/docs/specs/gitlab-mr-task-list-badges/spec.md
@@ -6,6 +6,11 @@ owner: tbd
# GitLab MR Badge on the Sidebar and Tasks-List Rows
+> **Amendment:** [Rich task title previews](../tasks/rich-task-title-previews.md)
+> extends this draft. It replaces the requirements that `MRTaskIcon` stay
+> unmodified, add no copy, and have only one hydration owner. The badge
+> placement and provider data-model requirements in this document still apply.
+
## Why
A task whose work lives on GitHub shows a pull-request badge on all three
diff --git a/docs/specs/tasks/rich-task-title-previews.md b/docs/specs/tasks/rich-task-title-previews.md
new file mode 100644
index 0000000000..75943ac4c9
--- /dev/null
+++ b/docs/specs/tasks/rich-task-title-previews.md
@@ -0,0 +1,67 @@
+---
+status: building
+created: 2026-08-18
+owner: tbd
+---
+
+# Rich task title previews
+
+## 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.
+
+## What
+
+- A fine-pointer task row shows a preview after the user points at its title.
+- 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.
+- 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.
+- On a task page, the mobile task switcher gives access to the same task tree.
+- GitHub and GitLab status summaries use one provider-neutral presentation contract.
+
+## Data model
+
+The feature adds no persistent data. It reads task and subtask data from the
+existing task store. It reads pull requests from `taskPRs.byTaskId`. It reads
+merge requests from `taskMRs.byWorkspaceId[workspaceId]`.
+
+The GitLab hydration hook shares an in-flight workspace request between mounted
+consumers. A later mount can start a fresh request. A successful request replaces
+the workspace cache. A failed request clears that workspace cache.
+
+## API surface
+
+The feature adds no HTTP route, WebSocket message, or plugin API. It uses the
+existing workspace task, pull-request, and merge-request contracts.
+
+The public status-summary presentation uses generic change-request concepts.
+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 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 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.
+- **GIVEN** a coarse-pointer device, **WHEN** a user taps the title, **THEN** the app opens the task without a preview.
+- **GIVEN** two mounted GitLab hydration consumers, **WHEN** both request one workspace, **THEN** the frontend sends one request.
+- **GIVEN** cached GitLab data, **WHEN** its refresh fails, **THEN** the cached workspace data is removed.
+
+## Out of scope
+
+- The feature does not add nested preview levels.
+- The feature does not add backend status projection fields.
+- The feature does not change mobile task navigation.
+
+## Implementation plan
+
+See [the implementation plan](../../plans/rich-task-title-previews/plan.md).