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