feat(web): add rich task and GitLab MR hover previews (#2610)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-21 11:38:21 -04:00
committed by GitHub
parent 8898459c0b
commit 7365db7064
53 changed files with 2697 additions and 168 deletions
+6 -3
View File
@@ -5,6 +5,7 @@ import { Badge } from "@kandev/ui/badge";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon";
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";
@@ -43,9 +44,11 @@ function PrimaryTaskLine({
hasPendingPermission: pendingInput.permission,
interrupted: task.interrupted,
})}
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
<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>
{showContributions && (
<span
className="inline-flex items-center gap-1"
@@ -3,9 +3,9 @@ import { cleanup, render } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
// AC12: TasksPageClient must call useWorkspaceMRs unconditionally, never
// gated on tasksListShowDetails. Every direct import of TasksPageClient is
// mocked so this test asserts only the wiring under AC12, not the behaviour
// AC12: TasksPageClient must hydrate both change-request providers
// unconditionally, never gated on tasksListShowDetails. Every direct import
// of TasksPageClient is mocked so this test asserts only the wiring, not the behaviour
// of the many unrelated hooks/components the page also renders (per the
// plan's U9 guidance: mock rather than exercise the whole page).
const useWorkspaceMRsMock = vi.fn();
@@ -109,16 +109,12 @@ function renderPage(tasksListShowDetails: boolean) {
);
}
describe("TasksPageClient MR hydration (AC12)", () => {
it("calls useWorkspaceMRs with the workspace id when task details are off", () => {
describe("TasksPageClient change-request hydration (AC12)", () => {
it("hydrates PRs and MRs with the workspace id when task details are off", () => {
renderPage(false);
expect(useWorkspaceMRsMock).toHaveBeenCalledWith("ws1");
// The gated GitHub call is unaffected: contrast case proving the two
// hooks are wired differently on purpose. Exact-call-record equality
// (not toHaveBeenCalledWith, a presence check) so the assertion fails
// if a regression also calls the hook ungated.
expect(useWorkspacePRsMock.mock.calls).toEqual([[null]]);
expect(useWorkspacePRsMock).toHaveBeenCalledWith("ws1");
});
it("calls useWorkspaceMRs with the workspace id when task details are on", () => {
+3 -4
View File
@@ -527,10 +527,9 @@ export function TasksPageClient(props: TasksPageClientProps) {
state.kanban.workflowId === s.activeWorkflowId ? state.kanban.steps : EMPTY_WORKFLOW_STEPS,
);
useWorkflowSnapshot(s.activeWorkflowId);
useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);
// Unconditional, unlike useWorkspacePRs above: useWorkspaceMRs(null) clears
// every workspace's cached MRs, not just this page's, and the sidebar (also
// mounted here) reads that same cache — see spec "Hydration ownership".
// Task-title previews render PR/MR glyphs independently of the list-details
// preference, so both workspace caches must be hydrated here.
useWorkspacePRs(s.activeWorkspaceId);
useWorkspaceMRs(s.activeWorkspaceId);
useForegroundRefresh(() => s.fetchTasks(true), Boolean(s.activeWorkspaceId), s.activeWorkspaceId);
const { handleSortChange, handleGroupChange } = useTasksListPreferenceSync({
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider, useAppStore } from "@/components/state-provider";
import { StateProvider } from "@/components/state-provider";
import type { TaskMR } from "@/lib/types/gitlab";
import { MRTaskIcon } from "./mr-task-icon";
@@ -39,19 +39,18 @@ function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
};
}
// Seeds the store and renders MRTaskIcon inside the same provider tree in one
// pass — each StateProvider mount owns an independent store instance, so a
// separate seed-then-render step would seed a store the render never reads.
// Seeds the store via StateProvider's initialState rather than a render-time
// setTaskMRs call, matching the pattern task-title-hover-card.test.tsx uses.
function renderMRTaskIcon(mrs: TaskMR[]) {
function Fixture() {
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
setTaskMRs("ws-1", { "task-1": mrs });
return <MRTaskIcon taskId="task-1" />;
}
return render(
<TooltipProvider>
<StateProvider initialState={{ workspaces: { items: [], activeId: "ws-1" } }}>
<Fixture />
<StateProvider
initialState={{
workspaces: { items: [], activeId: "ws-1" },
taskMRs: { byWorkspaceId: { "ws-1": { "task-1": mrs } } },
}}
>
<MRTaskIcon taskId="task-1" />
</StateProvider>
</TooltipProvider>,
);
@@ -80,8 +79,98 @@ describe("MRTaskIcon", () => {
renderMRTaskIcon([makeMR({ state: "merged" })]);
const badge = screen.getByTestId(BADGE_TEST_ID);
expect(badge.getAttribute("data-mr-state")).toBe("merged");
// The tooltip text itself (getMRTooltip's "State: merged" output) is
// covered directly in mr-task-icon.test.ts — jsdom does not reliably
// open Radix Tooltip content on a non-focusable trigger span.
});
// AC1, AC7, AC24: the trigger is keyboard-focusable and accessible, and the
// rendered summary uses MR !{iid} plus labelled rows — no pipe-delimited
// string anywhere.
it("AC1/AC7: trigger has role=img, tabIndex=0, and a localized aria-label", () => {
renderMRTaskIcon([makeMR({ mr_iid: 42 })]);
const badge = screen.getByTestId(BADGE_TEST_ID);
expect(badge.getAttribute("role")).toBe("img");
expect(badge.getAttribute("tabIndex")).toBe("0");
expect(badge.getAttribute("aria-label")).toBe("Merge request !42 status");
});
it("AC1/AC24: hovering the trigger opens the structured summary with MR !iid and the title", () => {
renderMRTaskIcon([
makeMR({
mr_iid: 7,
mr_title: "Fix the login flow",
approval_state: "approved",
pipeline_state: "success",
detailed_merge_status: "mergeable",
}),
]);
const badge = screen.getByTestId(BADGE_TEST_ID);
fireEvent.pointerEnter(badge, { pointerType: "mouse" });
// Radix mounts the tooltip content into both a measurement copy and the
// visible portal, so scope to the first (portal) match rather than
// asserting a single element.
expect(screen.getAllByTestId("mr-task-status-summary").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("mr-task-status-iid")[0].textContent).toBe("MR !7");
expect(screen.getAllByTestId("mr-task-status-title")[0].textContent).toBe("Fix the login flow");
expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Review");
expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Approved");
expect(screen.getAllByTestId("mr-task-status-ci")[0].textContent).toContain("CI");
expect(screen.getAllByTestId("mr-task-status-merge")[0].textContent).toContain("Merge");
expect(document.body.textContent).not.toContain("|");
});
it("AC1/AC24: the multi-MR badge renders one summary entry per MR, separated", () => {
renderMRTaskIcon([
makeMR({ id: "a", mr_iid: 11, mr_title: "First linked MR", pipeline_state: "success" }),
makeMR({ id: "b", mr_iid: 12, mr_title: "Second linked MR", pipeline_state: "failure" }),
]);
fireEvent.pointerEnter(screen.getByTestId(BADGE_TEST_ID), { pointerType: "mouse" });
// Radix force-mounts a measurement copy alongside the visible portal, so
// scope to one summary rather than counting matches across the document.
const summary = screen.getAllByTestId("mr-task-status-summary")[0];
const entries = summary.querySelectorAll('[data-testid="mr-task-status-entry"]');
expect(entries).toHaveLength(2);
expect(entries[0].textContent).toContain("MR !11");
expect(entries[0].textContent).toContain("First linked MR");
expect(entries[0].textContent).toContain("Passed");
expect(entries[1].textContent).toContain("MR !12");
expect(entries[1].textContent).toContain("Second linked MR");
expect(entries[1].textContent).toContain("Failed");
// Only the trailing entries carry the divider.
expect(entries[0].className).not.toContain("border-t");
expect(entries[1].className).toContain("border-t");
});
it("AC7: the multi-MR badge reports ready-to-merge only when every open MR is ready", () => {
const ready = {
pipeline_state: "success" as const,
detailed_merge_status: "mergeable",
};
renderMRTaskIcon([makeMR({ id: "a", ...ready }), makeMR({ id: "b", mr_iid: 2, ...ready })]);
expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("true");
cleanup();
renderMRTaskIcon([
makeMR({ id: "a", ...ready }),
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
]);
expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("false");
});
it("malformed store value still bails to null instead of throwing", () => {
render(
<TooltipProvider>
<StateProvider
initialState={{
workspaces: { items: [], activeId: "ws-1" },
// @ts-expect-error simulating a partial hydration payload
taskMRs: { byWorkspaceId: { "ws-1": { "task-1": { not: "an array" } } } },
}}
>
<MRTaskIcon taskId="task-1" />
</StateProvider>
</TooltipProvider>,
);
expect(screen.queryByTestId(BADGE_TEST_ID)).toBeNull();
});
});
+29 -32
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import {
aggregateMRChipStatus,
aggregateMRStatusColor,
areAllOpenMRsReadyToMerge,
compareChipMR,
getMRStatusColor,
getMRTooltip,
isMRReadyToMerge,
mrChipStatus,
MR_CHIP_STATUS_RANK,
@@ -95,37 +95,6 @@ describe("getMRStatusColor", () => {
});
});
// AC37: colour is never the only carrier of MR state — the tooltip text
// states it explicitly for any non-open MR.
describe("getMRTooltip", () => {
it("states the MR state for a non-open MR", () => {
expect(getMRTooltip(makeMR({ state: "merged" }))).toContain("State: merged");
expect(getMRTooltip(makeMR({ state: "closed" }))).toContain("State: closed");
});
it("omits the state line for an open MR", () => {
expect(getMRTooltip(makeMR({ state: "open" }))).not.toContain("State:");
});
it("flags a ready-to-merge MR", () => {
expect(
getMRTooltip(
makeMR({
approval_state: "approved",
pipeline_state: "success",
merge_status: "can_be_merged",
}),
),
).toContain("Ready to merge");
});
it("flags a draft MR without claiming it's ready to merge", () => {
const tooltip = getMRTooltip(makeMR({ draft: true }));
expect(tooltip).toContain("Draft");
expect(tooltip).not.toContain("Ready to merge");
});
});
describe("isMRReadyToMerge", () => {
it("is true only for open, non-draft MRs with a passing pipeline and a mergeable status", () => {
expect(
@@ -471,3 +440,31 @@ describe("compareChipMR", () => {
expect(compareChipMR(a, a)).toBe(0);
});
});
// AC7: data-mr-ready-to-merge on the multi-MR badge, mirroring github's
// areAllOpenPRsReadyToMerge.
describe("areAllOpenMRsReadyToMerge", () => {
const readyMR = makeMR({
approval_state: "approved",
pipeline_state: "success",
merge_status: "can_be_merged",
});
it("is false when there are no open MRs", () => {
expect(areAllOpenMRsReadyToMerge([makeMR({ state: "merged" })])).toBe(false);
});
it("is true when every open MR is ready to merge", () => {
expect(areAllOpenMRsReadyToMerge([readyMR, { ...readyMR, id: "b", mr_iid: 2 }])).toBe(true);
});
it("is false when any open MR is not ready to merge", () => {
const notReady = makeMR({ id: "b", mr_iid: 2, pipeline_state: "pending" });
expect(areAllOpenMRsReadyToMerge([readyMR, notReady])).toBe(false);
});
it("ignores terminal siblings when deciding readiness", () => {
const merged = makeMR({ id: "c", mr_iid: 3, state: "merged" });
expect(areAllOpenMRsReadyToMerge([readyMR, merged])).toBe(true);
});
});
+53 -29
View File
@@ -2,10 +2,12 @@
import { IconGitMerge } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { t } from "@/lib/i18n";
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
import type { TaskMR } from "@/lib/types/gitlab";
import { deriveMRTaskStatusSummary, MRTaskStatusSummary } from "./mr-task-status-summary";
const MUTED_FOREGROUND = "text-muted-foreground";
const PURPLE_500 = "text-purple-500";
@@ -170,14 +172,14 @@ function mrMergeStatusReady(mr: TaskMR): boolean {
return mr.merge_status === "can_be_merged";
}
export function getMRTooltip(mr: TaskMR): string {
const parts = [`!${mr.mr_iid}: ${mr.mr_title}`];
if (mr.state !== "open") parts.push(t("gitlab:mrTooltipState", { state: mr.state }));
if (mr.approval_state) parts.push(t("gitlab:mrTooltipReview", { state: mr.approval_state }));
if (mr.pipeline_state) parts.push(t("gitlab:mrTooltipPipeline", { state: mr.pipeline_state }));
if (mr.draft) parts.push(t("gitlab:draft"));
else if (isMRReadyToMerge(mr)) parts.push(t("gitlab:mrTooltipReadyToMerge"));
return parts.join(" | ");
/**
* True when at least one MR is open AND every open MR is ready to merge.
* Terminal (merged/closed/locked) siblings are ignored so they can't drag the
* result to false. Mirrors github's areAllOpenPRsReadyToMerge.
*/
export function areAllOpenMRsReadyToMerge(mrs: TaskMR[]): boolean {
const openMRs = mrs.filter((mr) => mr.state === "open");
return openMRs.length > 0 && openMRs.every(isMRReadyToMerge);
}
/**
@@ -226,51 +228,73 @@ export function MRTaskIcon({ taskId }: { taskId: string }) {
}
function SingleMRIcon({ taskId, mr }: { taskId: string; mr: TaskMR }) {
const { t } = useTranslation();
const tooltip = useChangeRequestTaskTooltipState();
const readyToMerge = isMRReadyToMerge(mr);
const summary = deriveMRTaskStatusSummary(mr, readyToMerge);
return (
<Tooltip>
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<span
data-testid={`mr-task-icon-${taskId}`}
data-mr-state={mr.state}
data-mr-count="1"
data-mr-ready-to-merge={isMRReadyToMerge(mr) ? "true" : "false"}
data-mr-ready-to-merge={readyToMerge ? "true" : "false"}
role="img"
tabIndex={0}
aria-label={t("gitlab:mergeRequestStatus", { iid: mr.mr_iid })}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn("inline-flex items-center shrink-0", getMRStatusColor(mr))}
>
<IconGitMerge className="h-3.5 w-3.5" />
<IconGitMerge aria-hidden="true" className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
{/* MR title/state come from the linked GitLab MR itself, not
translatable UI copy — mirrors github's untranslated getPRTooltip. */}
<TooltipContent>{getMRTooltip(mr)}</TooltipContent>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<MRTaskStatusSummary summaries={[summary]} />
</TooltipContent>
</Tooltip>
);
}
function MultiMRIcon({ taskId, mrs }: { taskId: string; mrs: TaskMR[] }) {
const { t } = useTranslation();
const tooltip = useChangeRequestTaskTooltipState();
const aggregateColor = aggregateMRStatusColor(mrs);
const allReady = areAllOpenMRsReadyToMerge(mrs);
const summaries = mrs.map((mr) => deriveMRTaskStatusSummary(mr, isMRReadyToMerge(mr)));
return (
<Tooltip>
<Tooltip open={tooltip.open}>
<TooltipTrigger asChild>
<span
data-testid={`mr-task-icon-${taskId}`}
data-mr-count={mrs.length}
data-mr-ready-to-merge={allReady ? "true" : "false"}
role="img"
tabIndex={0}
aria-label={t("gitlab:mergeRequestStatuses", { count: mrs.length })}
onPointerEnter={tooltip.onPointerEnter}
onPointerLeave={tooltip.onPointerLeave}
onFocus={tooltip.onFocus}
onBlur={tooltip.onBlur}
className={cn("inline-flex items-center gap-0.5 shrink-0", aggregateColor)}
>
<IconGitMerge className="h-3.5 w-3.5" />
<span className="text-[9px] font-semibold leading-none">{mrs.length}</span>
<IconGitMerge aria-hidden="true" className="h-3.5 w-3.5" />
<span className="text-[9px] font-semibold leading-none tabular-nums">{mrs.length}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1 text-xs">
{mrs.map((mr) => (
<div key={mr.id} className="flex items-center gap-2">
<span className={cn("inline-flex shrink-0", getMRStatusColor(mr))}>
<IconGitMerge className="h-3 w-3" />
</span>
<span>{getMRTooltip(mr)}</span>
</div>
))}
</div>
<TooltipContent
sideOffset={6}
onEscapeKeyDown={tooltip.onEscapeKeyDown}
className="w-80 max-w-[calc(100vw-1rem)] p-3"
>
<MRTaskStatusSummary summaries={summaries} />
</TooltipContent>
</Tooltip>
);
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import type { TaskMR } from "@/lib/types/gitlab";
import { deriveMRTaskStatusSummary } from "./mr-task-status-summary";
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
return {
id: "id",
task_id: "task-1",
host: "https://gitlab.com",
project_path: "group/project",
mr_iid: 12,
mr_url: "",
mr_title: "Fix the thing",
head_branch: "feature",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
reviewer_count: 0,
unapproved_reviewers: 0,
unresolved_discussions: 0,
created_at: "",
updated_at: "",
...overrides,
};
}
describe("deriveMRTaskStatusSummary", () => {
it("derives review, CI, and merge rows for an open, approved, mergeable MR", () => {
const mr = makeMR({
approval_state: "approved",
pipeline_state: "success",
detailed_merge_status: "mergeable",
});
const summary = deriveMRTaskStatusSummary(mr, true);
expect(summary).toEqual({
iid: 12,
title: "Fix the thing",
rows: [
{ kind: "review", status: "approved", tone: "success" },
{ kind: "ci", status: "passed", tone: "success" },
{ kind: "merge", status: "ready", tone: "success" },
],
});
});
it("renders CI failed in the danger tone", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ pipeline_state: "failure" }), false);
expect(summary.rows).toContainEqual({ kind: "ci", status: "failed", tone: "danger" });
});
it("renders merge conflicts in the danger tone from detailed_merge_status", () => {
const summary = deriveMRTaskStatusSummary(
makeMR({ detailed_merge_status: "cannot_be_merged" }),
false,
);
expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" });
});
it("renders merge conflicts from the legacy merge_status when detailed_merge_status is absent", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ merge_status: "cannot_be_merged" }), false);
expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" });
});
it("renders a draft MR with the draft merge row", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ draft: true }), false);
expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]);
});
it("renders a State row and omits the Merge row for a merged MR", () => {
const summary = deriveMRTaskStatusSummary(
makeMR({
state: "merged",
pipeline_state: "success",
detailed_merge_status: "mergeable",
}),
false,
);
expect(summary.rows).toEqual([
{ kind: "state", status: "merged", tone: "merged" },
{ kind: "ci", status: "passed", tone: "success" },
]);
});
it("renders a Closed state row for a closed MR", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), false);
expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]);
});
it("renders a Closed state row for a locked MR", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ state: "locked" }), false);
expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]);
});
});
describe("deriveMRTaskStatusSummary — empty and unrecognized values", () => {
it("renders no rows for an MR with every optional field empty", () => {
const summary = deriveMRTaskStatusSummary(makeMR(), false);
expect(summary.rows).toEqual([]);
expect(summary.iid).toBe(12);
expect(summary.title).toBe("Fix the thing");
});
it("preserves an unrecognized pipeline_state verbatim in a raw row", () => {
const summary = deriveMRTaskStatusSummary(
makeMR({ pipeline_state: "canceled" as unknown as TaskMR["pipeline_state"] }),
false,
);
expect(summary.rows).toContainEqual({
kind: "ci",
status: "raw",
tone: "muted",
rawValue: "canceled",
});
});
it("preserves an unrecognized detailed_merge_status verbatim in a raw row", () => {
const summary = deriveMRTaskStatusSummary(
makeMR({ detailed_merge_status: "policies_denied" }),
false,
);
expect(summary.rows).toContainEqual({
kind: "merge",
status: "raw",
tone: "muted",
rawValue: "policies_denied",
});
});
it("omits the merge row entirely for checking/unchecked/ci_still_running (the CI row already says it)", () => {
for (const status of ["checking", "unchecked", "ci_still_running"]) {
const summary = deriveMRTaskStatusSummary(makeMR({ detailed_merge_status: status }), false);
expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined();
}
});
it("renders an awaiting-approval review row in the info tone", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ approval_state: "pending" }), false);
expect(summary.rows).toContainEqual({
kind: "review",
status: "awaiting_approval",
tone: "info",
});
});
it("does not render a merge row for a non-open MR even when readyToMerge is true", () => {
const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), true);
expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined();
});
});
@@ -0,0 +1,142 @@
"use client";
import { IconGitMerge } from "@tabler/icons-react";
import type { TaskMR } from "@/lib/types/gitlab";
import {
ChangeRequestTaskStatusSummary,
type ChangeRequestTaskStatusPresentation,
type ChangeRequestTaskSummaryRow,
type ChangeRequestTaskSummaryRowKind,
type ChangeRequestTaskSummaryStatus,
type ChangeRequestTaskSummaryTone,
} from "@/components/integrations/change-request-task-status-summary";
export type MRTaskSummaryRowKind = ChangeRequestTaskSummaryRowKind;
export type MRTaskSummaryTone = ChangeRequestTaskSummaryTone;
export type MRTaskSummaryStatus = ChangeRequestTaskSummaryStatus;
export type MRTaskSummaryRow = ChangeRequestTaskSummaryRow;
export type MRTaskStatusSummaryData = {
iid: number;
title: string;
rows: MRTaskSummaryRow[];
};
function rawRow(kind: MRTaskSummaryRowKind, rawValue: string): MRTaskSummaryRow {
return { kind, status: "raw", tone: "muted", rawValue };
}
function deriveStateRow(state: string): MRTaskSummaryRow | null {
if (!state || state === "open") return null;
if (state === "merged") return { kind: "state", status: "merged", tone: "merged" };
if (state === "closed" || state === "locked") {
return { kind: "state", status: "closed", tone: "danger" };
}
return rawRow("state", state);
}
function deriveReviewRow(approvalState: string): MRTaskSummaryRow | null {
if (!approvalState) return null;
if (approvalState === "approved") {
return { kind: "review", status: "approved", tone: "success" };
}
if (approvalState === "pending") {
return { kind: "review", status: "awaiting_approval", tone: "info" };
}
return rawRow("review", approvalState);
}
function deriveCIRow(pipelineState: string): MRTaskSummaryRow | null {
if (!pipelineState) return null;
if (pipelineState === "success") return { kind: "ci", status: "passed", tone: "success" };
if (pipelineState === "failure") return { kind: "ci", status: "failed", tone: "danger" };
if (pipelineState === "pending") return { kind: "ci", status: "in_progress", tone: "warning" };
return rawRow("ci", pipelineState);
}
// checking/unchecked/ci_still_running produce no merge row: the CI row above
// already communicates the wait, so a second "still checking" line would be
// redundant noise.
const MERGE_STATUS_NO_ROW = new Set(["checking", "unchecked", "ci_still_running"]);
function deriveMergeStatusRow(mergeStatus: string): MRTaskSummaryRow | null {
if (!mergeStatus || MERGE_STATUS_NO_ROW.has(mergeStatus)) return null;
if (mergeStatus === "mergeable" || mergeStatus === "can_be_merged") {
return { kind: "merge", status: "mergeable", tone: "muted" };
}
if (mergeStatus === "broken_status" || mergeStatus === "cannot_be_merged") {
return { kind: "merge", status: "conflicts", tone: "danger" };
}
if (mergeStatus === "discussions_not_resolved") {
return { kind: "merge", status: "unresolved_discussions", tone: "warning" };
}
if (mergeStatus === "not_approved") {
return { kind: "merge", status: "awaiting_approval", tone: "muted" };
}
return rawRow("merge", mergeStatus);
}
function deriveMergeRow(mr: TaskMR, readyToMerge: boolean): MRTaskSummaryRow | null {
if (mr.state !== "open") return null;
if (mr.draft) return { kind: "merge", status: "draft", tone: "muted" };
if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" };
return deriveMergeStatusRow(mr.detailed_merge_status || mr.merge_status);
}
export function deriveMRTaskStatusSummary(
mr: TaskMR,
readyToMerge: boolean,
): MRTaskStatusSummaryData {
const rows = [
deriveStateRow(mr.state),
deriveReviewRow(mr.approval_state),
deriveCIRow(mr.pipeline_state),
deriveMergeRow(mr, readyToMerge),
].filter((row): row is MRTaskSummaryRow => row !== null);
return { iid: mr.mr_iid, title: mr.mr_title, rows };
}
const MR_PRESENTATION: ChangeRequestTaskStatusPresentation = {
summaryTestId: "mr-task-status-summary",
entryTestId: "mr-task-status-entry",
identifierTestId: "mr-task-status-iid",
titleTestId: "mr-task-status-title",
rowTestIdPrefix: "mr-task-status",
icon: IconGitMerge,
entryAriaLabelKey: "gitlab:mergeRequestStatus",
identifierLabelKey: "gitlab:mrTaskStatusIid",
rowLabelKeys: {
state: "gitlab:mrTaskStatusState",
review: "gitlab:mrTaskStatusReview",
ci: "gitlab:mrTaskStatusCi",
merge: "gitlab:mrTaskStatusMerge",
},
statusLabelKeys: {
merged: "gitlab:merged",
closed: "gitlab:closed",
approved: "gitlab:approved",
awaiting_approval: "gitlab:awaitingApproval",
passed: "gitlab:passed",
failed: "gitlab:failed",
in_progress: "gitlab:inProgress",
draft: "gitlab:draft",
ready: "gitlab:readyToMerge",
mergeable: "gitlab:mergeable",
conflicts: "gitlab:conflicts",
unresolved_discussions: "gitlab:unresolvedDiscussions",
},
};
export function MRTaskStatusSummary({ summaries }: { summaries: MRTaskStatusSummaryData[] }) {
return (
<ChangeRequestTaskStatusSummary
summaries={summaries.map((summary) => ({
number: summary.iid,
title: summary.title,
rows: summary.rows,
}))}
presentation={MR_PRESENTATION}
/>
);
}
@@ -29,6 +29,7 @@ export type ChangeRequestTaskSummaryStatus =
| "approved"
| "changes_requested"
| "pending_review"
| "awaiting_approval"
| "passed"
| "failed"
| "in_progress"
@@ -37,6 +38,7 @@ export type ChangeRequestTaskSummaryStatus =
| "conflicts"
| "behind"
| "blocked"
| "unresolved_discussions"
| "mergeable"
| "raw";
@@ -53,6 +55,19 @@ export type ChangeRequestTaskStatusSummaryData = {
rows: ChangeRequestTaskSummaryRow[];
};
export type ChangeRequestTaskStatusPresentation = {
summaryTestId: string;
entryTestId: string;
identifierTestId: string;
titleTestId: string;
rowTestIdPrefix: string;
icon: TablerIcon;
entryAriaLabelKey: string;
identifierLabelKey: string;
rowLabelKeys: Record<ChangeRequestTaskSummaryRowKind, string>;
statusLabelKeys?: Partial<Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">, string>>;
};
const ROW_LABEL_KEYS: Record<ChangeRequestTaskSummaryRowKind, string> = {
state: "github:prTaskStatusState",
review: "github:prTaskStatusReview",
@@ -75,6 +90,7 @@ const STATUS_LABEL_KEYS: Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">,
approved: "github:approved",
changes_requested: "github:changesRequested",
pending_review: "github:pendingReview",
awaiting_approval: "github:pendingReview",
passed: "github:checkBucketPassed",
failed: "github:checkBucketFailed",
in_progress: "github:checkBucketInProgress",
@@ -83,6 +99,7 @@ const STATUS_LABEL_KEYS: Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">,
conflicts: "github:conflicts",
behind: "github:behindBase",
blocked: "github:blocked",
unresolved_discussions: "github:blocked",
mergeable: "github:mergeable",
};
@@ -92,6 +109,7 @@ const STATUS_ICONS: Record<ChangeRequestTaskSummaryStatus, TablerIcon> = {
approved: IconCheck,
changes_requested: IconX,
pending_review: IconClockHour4,
awaiting_approval: IconClockHour4,
passed: IconCheck,
failed: IconX,
in_progress: IconClockHour4,
@@ -100,13 +118,30 @@ const STATUS_ICONS: Record<ChangeRequestTaskSummaryStatus, TablerIcon> = {
conflicts: IconAlertTriangle,
behind: IconAlertTriangle,
blocked: IconAlertTriangle,
unresolved_discussions: IconAlertTriangle,
mergeable: IconGitMerge,
raw: IconCircleDot,
};
function getStatusText(row: ChangeRequestTaskSummaryRow, t: TFunction): string {
const DEFAULT_PRESENTATION: ChangeRequestTaskStatusPresentation = {
summaryTestId: "pr-task-status-summary",
entryTestId: "pr-task-status-entry",
identifierTestId: "pr-task-status-number",
titleTestId: "pr-task-status-title",
rowTestIdPrefix: "pr-task-status",
icon: IconGitPullRequest,
entryAriaLabelKey: "github:pullRequestStatus",
identifierLabelKey: "github:prTaskStatusNumber",
rowLabelKeys: ROW_LABEL_KEYS,
};
function getStatusText(
row: ChangeRequestTaskSummaryRow,
presentation: ChangeRequestTaskStatusPresentation,
t: TFunction,
): string {
if (row.status === "raw") return row.rawValue ?? "";
return t(STATUS_LABEL_KEYS[row.status]);
return t(presentation.statusLabelKeys?.[row.status] ?? STATUS_LABEL_KEYS[row.status]);
}
function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus }) {
@@ -114,17 +149,23 @@ function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus
return <StatusIcon aria-hidden="true" className="size-3.5 shrink-0" />;
}
function SummaryRow({ row }: { row: ChangeRequestTaskSummaryRow }) {
function SummaryRow({
row,
presentation,
}: {
row: ChangeRequestTaskSummaryRow;
presentation: ChangeRequestTaskStatusPresentation;
}) {
const { t } = useTranslation();
return (
<div
data-testid={`pr-task-status-${row.kind}`}
data-testid={`${presentation.rowTestIdPrefix}-${row.kind}`}
className="grid grid-cols-[min-content_minmax(0,1fr)] items-start gap-x-3"
>
<span className="text-muted-foreground">{t(ROW_LABEL_KEYS[row.kind])}</span>
<span className="text-muted-foreground">{t(presentation.rowLabelKeys[row.kind])}</span>
<span className={cn("flex min-w-0 items-center gap-1.5 font-medium", TONE_CLASSES[row.tone])}>
<SummaryStatusIcon status={row.status} />
<span className="min-w-0 break-words">{getStatusText(row, t)}</span>
<span className="min-w-0 break-words">{getStatusText(row, presentation, t)}</span>
</span>
</div>
);
@@ -132,33 +173,42 @@ function SummaryRow({ row }: { row: ChangeRequestTaskSummaryRow }) {
export function ChangeRequestTaskStatusSummary({
summaries,
presentation = DEFAULT_PRESENTATION,
}: {
summaries: ChangeRequestTaskStatusSummaryData[];
presentation?: ChangeRequestTaskStatusPresentation;
}) {
const { t } = useTranslation();
const ProviderIcon = presentation.icon;
return (
<div data-testid="pr-task-status-summary" className="w-full">
<div data-testid={presentation.summaryTestId} className="w-full">
{summaries.map((summary, index) => (
<section
key={`${summary.number}-${index}`}
data-testid="pr-task-status-entry"
aria-label={t("github:pullRequestStatus", { number: summary.number })}
data-testid={presentation.entryTestId}
aria-label={t(presentation.entryAriaLabelKey, {
number: summary.number,
iid: summary.number,
})}
className={cn(index > 0 && "mt-3 border-t border-border/60 pt-3")}
>
<div className="flex items-start gap-2">
<IconGitPullRequest
<ProviderIcon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1">
<div
data-testid="pr-task-status-number"
data-testid={presentation.identifierTestId}
className="text-[11px] font-medium tabular-nums text-muted-foreground"
>
{t("github:prTaskStatusNumber", { number: summary.number })}
{t(presentation.identifierLabelKey, {
number: summary.number,
iid: summary.number,
})}
</div>
<div
data-testid="pr-task-status-title"
data-testid={presentation.titleTestId}
className="text-pretty break-words text-sm font-medium leading-snug text-foreground [overflow-wrap:anywhere]"
>
{summary.title}
@@ -168,7 +218,7 @@ export function ChangeRequestTaskStatusSummary({
{summary.rows.length > 0 && (
<div className="mt-2.5 space-y-1.5 pl-6">
{summary.rows.map((row) => (
<SummaryRow key={row.kind} row={row} />
<SummaryRow key={row.kind} row={row} presentation={presentation} />
))}
</div>
)}
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const mockAppState = {
workspaces: { activeId: "ws-1" },
kanban: { tasks: [] as Array<{ id: string; title: string }> },
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: string }> }> },
taskPRs: { byTaskId: {} as Record<string, unknown> },
};
@@ -34,6 +35,7 @@ const SECOND_PLUGIN_ID = "kandev-plugin-second";
const TAGS_TEST_ID = "plugin-tags";
const TAGS_SLOT = "task-card-tags";
const INDICATOR_TEST_ID = "plugin-indicator";
const TITLE_TEST_ID = "task-card-title";
const SLOT_PROPS_TEXT = "task-1|ws-1|step-1";
function SlotPropsProbe({ testId, slotProps }: { testId: string; slotProps?: unknown }) {
@@ -139,7 +141,7 @@ describe("KanbanCardBody — task-card-tags slot", () => {
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
expect(screen.getByTestId("task-card-title").textContent).toBe(TASK.title);
expect(screen.getByTestId(TITLE_TEST_ID).textContent).toBe(TASK.title);
expect(screen.getByTestId(INDICATOR_TEST_ID)).toBeTruthy();
} finally {
consoleError.mockRestore();
@@ -150,7 +152,7 @@ describe("KanbanCardBody — task-card-tags slot", () => {
describe("KanbanCardBody — linked PR/MR badges (AC30)", () => {
it("renders both the PR badge and the MR badge, PR first", () => {
const { container } = render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
const title = screen.getByTestId("task-card-title");
const title = screen.getByTestId(TITLE_TEST_ID);
const row = title.parentElement;
expect(row).not.toBeNull();
const prIcon = screen.getByTestId("pr-task-icon");
@@ -161,3 +163,17 @@ describe("KanbanCardBody — linked PR/MR badges (AC30)", () => {
expect(prIcon.compareDocumentPosition(mrIcon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe("KanbanCardBody — title hover card gating", () => {
it("does not mount a hover card trigger when enableTitleHover is unset (drag preview)", () => {
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
const title = screen.getByTestId(TITLE_TEST_ID);
expect(title.getAttribute("data-slot")).not.toBe("hover-card-trigger");
});
it("mounts the hover card trigger around the title when enableTitleHover is set", () => {
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
const title = screen.getByTestId(TITLE_TEST_ID);
expect(title.closest('[data-testid="task-title-preview-trigger"]')).not.toBeNull();
});
});
+5 -6
View File
@@ -26,6 +26,7 @@ import {
type KanbanCardMenuEntry,
} from "@/components/kanban-card-menu-items";
import { TaskCardIndicators, TaskCardTags } from "@/components/kanban-card-plugin-slots";
import { CardTitle } from "@/components/kanban-card-title";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
@@ -133,10 +134,12 @@ export function KanbanCardBody({
task,
repositoryChips,
actions,
enableTitleHover,
}: {
task: Task;
repositoryChips: RepositoryChip[];
actions?: React.ReactNode;
enableTitleHover?: boolean;
}) {
return (
<>
@@ -144,12 +147,7 @@ export function KanbanCardBody({
<div className="min-w-0 flex-1">
<RepoChipRow chips={repositoryChips} />
<div className="flex items-center gap-1 min-w-0" data-testid="kanban-card-title-row">
<p
data-testid="task-card-title"
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
>
{task.title}
</p>
<CardTitle task={task} enableTitleHover={enableTitleHover} />
<PRTaskIcon taskId={task.id} />
<MRTaskIcon taskId={task.id} />
<RegisteredChangeRequestTaskIcon taskId={task.id} />
@@ -636,6 +634,7 @@ export function KanbanCardShell({
<KanbanCardBody
task={task}
repositoryChips={repositoryChips ?? []}
enableTitleHover
actions={
<KanbanCardActionSlot
isMultiSelectMode={isMultiSelectMode}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
import type { Task } from "@/components/kanban-card";
/** The kanban card's title, optionally wrapped in the full-title/subtasks hover card. */
export function CardTitle({ task, enableTitleHover }: { task: Task; enableTitleHover?: boolean }) {
const title = (
<p
data-testid="task-card-title"
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
>
{task.title}
</p>
);
if (!enableTitleHover) return title;
return (
<TaskTitleHoverCard taskId={task.id} title={task.title}>
{title}
</TaskTitleHoverCard>
);
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { act } from "react";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
@@ -12,10 +12,12 @@ vi.mock("@/components/toast-provider", () => ({
// actually sees. A key-echoing stub silently turns every migrated label into a
// raw `ns:key` and the assertions then only prove the stub works.
import { FileContextMenu } from "./file-context-menu";
import { FileContextMenu, TreeNodeName, useFileRename } from "./file-context-menu";
const FILE_NODE: FileTreeNode = { name: "README.md", path: "README.md", is_dir: false, size: 0 };
const DIR_NODE: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 };
const RENAME_ROW = "rename-row";
const FOCUS_ANCHOR = "focus-anchor";
const BULK_TREE: FileTreeNode = {
name: "root",
path: "",
@@ -27,7 +29,10 @@ const BULK_TREE: FileTreeNode = {
],
};
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.useRealTimers();
});
function openMenu(triggerTestId: string) {
const trigger = screen.getByTestId(triggerTestId);
@@ -56,6 +61,138 @@ function BulkDeleteHarness({ onDeleteFile }: { onDeleteFile: (path: string) => P
);
}
function RenameHarness() {
const [tree, setTree] = React.useState<FileTreeNode | null>(FILE_NODE);
const rename = useFileRename(FILE_NODE, tree, setTree, vi.fn().mockResolvedValue(true));
return (
<FileContextMenu
node={FILE_NODE}
tree={tree}
setTree={setTree}
onRenameFile={vi.fn().mockResolvedValue(true)}
onStartRename={rename.handleStartRename}
>
<div data-testid={RENAME_ROW}>
<TreeNodeName node={FILE_NODE} isActive={false} gitStatus={undefined} rename={rename} />
</div>
</FileContextMenu>
);
}
// `FileContextMenuSurface` defers `onStartRename` to `onCloseAutoFocus` and calls
// `preventDefault()` there so Radix does not pull focus back to the trigger and
// steal it from the freshly mounted input. Both halves of that are only correct
// if the deferral is scoped to a *pending* rename: any other menu item, and any
// dismissal, must leave Radix's default focus restoration alone and must never
// enter edit mode. A pending flag that outlives its close is the failure mode.
// The anchor stands in for whatever held focus when the user opened the menu.
// Radix restores focus to it on close, so asserting against it distinguishes a
// real restoration from focus falling back to `document.body`.
function PendingRenameHarness({ onStartRename }: { onStartRename: () => void }) {
const [tree, setTree] = React.useState<FileTreeNode | null>(FILE_NODE);
return (
<>
<button data-testid={FOCUS_ANCHOR}>anchor</button>
<FileContextMenu
node={FILE_NODE}
tree={tree}
setTree={setTree}
onRenameFile={vi.fn().mockResolvedValue(true)}
onDownloadFile={vi.fn().mockResolvedValue(true)}
onStartRename={onStartRename}
>
<div data-testid={RENAME_ROW}>row</div>
</FileContextMenu>
</>
);
}
describe("FileContextMenu rename", () => {
it("does not start rename when a different menu item is selected", async () => {
const onStartRename = vi.fn();
render(<PendingRenameHarness onStartRename={onStartRename} />);
openMenu(RENAME_ROW);
fireEvent.click(screen.getByText("Download"));
await waitFor(() => expect(screen.queryByText("Download")).toBeNull());
expect(onStartRename).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox")).toBeNull();
});
it("does not start rename when the menu is dismissed without a selection", async () => {
const onStartRename = vi.fn();
render(<PendingRenameHarness onStartRename={onStartRename} />);
openMenu(RENAME_ROW);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByText("Rename")).toBeNull());
expect(onStartRename).not.toHaveBeenCalled();
});
it("does not replay a consumed rename on the next menu close", async () => {
const onStartRename = vi.fn();
render(<PendingRenameHarness onStartRename={onStartRename} />);
openMenu(RENAME_ROW);
fireEvent.click(screen.getByText("Rename"));
await waitFor(() => expect(onStartRename).toHaveBeenCalledTimes(1));
// Reopening and dismissing must not re-fire the rename the previous close
// already consumed.
openMenu(RENAME_ROW);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByText("Rename")).toBeNull());
expect(onStartRename).toHaveBeenCalledTimes(1);
});
// The three tests above prove `onStartRename` stays unfired, which is only
// half of the contract. `preventDefault()` suppresses Radix's focus
// restoration, and hoisting it above the pending-rename guard would keep every
// assertion above green while silently dropping the user back on
// `document.body` after Download or Escape. These two pin the other half.
it("restores focus to the opener when a different menu item is selected", async () => {
render(<PendingRenameHarness onStartRename={vi.fn()} />);
const anchor = screen.getByTestId(FOCUS_ANCHOR);
anchor.focus();
openMenu(RENAME_ROW);
fireEvent.click(screen.getByText("Download"));
await waitFor(() => expect(document.activeElement).toBe(anchor));
});
it("restores focus to the opener when the menu is dismissed without a selection", async () => {
render(<PendingRenameHarness onStartRename={vi.fn()} />);
const anchor = screen.getByTestId(FOCUS_ANCHOR);
anchor.focus();
openMenu(RENAME_ROW);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(document.activeElement).toBe(anchor));
});
it("starts rename after the menu closes and immediately focuses the selected filename", async () => {
vi.useFakeTimers();
render(<RenameHarness />);
openMenu(RENAME_ROW);
fireEvent.click(screen.getByText("Rename"));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const input = screen.getByRole("textbox");
expect(document.activeElement).toBe(input);
expect((input as HTMLInputElement).selectionStart).toBe(0);
expect((input as HTMLInputElement).selectionEnd).toBe(FILE_NODE.name.length);
});
});
describe("FileContextMenu Download item", () => {
it("shows a Download item for a file when onDownloadFile is provided", () => {
const onDownloadFile = vi.fn().mockResolvedValue(true);
+20 -7
View File
@@ -294,11 +294,27 @@ function FileContextMenuSurface({
onConfirmDelete,
onDelete,
}: FileContextMenuSurfaceProps) {
const renamePendingRef = useRef(false);
const handleStartRename = useCallback(() => {
renamePendingRef.current = true;
}, []);
const handleCloseAutoFocus = useCallback(
(event: Event) => {
if (!renamePendingRef.current) return;
event.preventDefault();
renamePendingRef.current = false;
onStartRename();
},
[onStartRename],
);
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
{showOpenInEditor && <OpenInEditorMenuItems node={node} />}
{showOpenInEditor && (hasFileActions || showAddToChatContext) && <ContextMenuSeparator />}
{showAddToChatContext && onAddToChatContext && (
@@ -312,7 +328,7 @@ function FileContextMenuSurface({
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onDownloadFile={onDownloadFile}
onStartRename={onStartRename}
onStartRename={handleStartRename}
onDelete={onDelete}
/>
</ContextMenuContent>
@@ -504,15 +520,12 @@ export function TreeNodeName({
useEffect(() => {
if (rename.isRenaming) {
blurEnabledRef.current = false;
const focusTimer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 150);
inputRef.current?.focus();
inputRef.current?.select();
const blurTimer = setTimeout(() => {
blurEnabledRef.current = true;
}, 400);
return () => {
clearTimeout(focusTimer);
clearTimeout(blurTimer);
};
}
+17 -1
View File
@@ -32,6 +32,7 @@ import { classifyTask } from "./task-classify";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { useTranslation } from "react-i18next";
import { TaskAutopilotIcon } from "@/components/task/task-autopilot-icon";
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon";
@@ -314,6 +315,21 @@ 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 TaskItemContent({
title,
autopilot,
@@ -357,7 +373,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">
<ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>
<TaskItemTitle taskId={taskId} title={title} />
{autopilot && <TaskAutopilotIcon />}
{isPinned && (
<IconPinFilled
@@ -29,6 +29,7 @@ import {
} from "./task-select-helpers";
import { useArchivedTaskState } from "./task-archived-context";
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
@@ -458,6 +459,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
const store = useAppStoreApi();
const { t } = useTranslation();
useRepositories(workspaceId);
useWorkspaceMRs(workspaceId);
const pathname = usePathname();
const {
@@ -0,0 +1,67 @@
"use client";
import { IconGitMerge, IconGitPullRequest } from "@tabler/icons-react";
import Link from "@/components/routing/app-link";
import { useAppStore } from "@/components/state-provider";
import { aggregatePRStatusColor } from "@/components/github/pr-task-icon";
import { aggregateMRStatusColor } from "@/components/gitlab/mr-task-icon";
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { linkToTask } from "@/lib/links";
import { getTaskStateIcon } from "@/lib/ui/state-icons";
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
import { cn } from "@/lib/utils";
import type { TaskSubtask } from "@/hooks/domains/kanban/use-task-subtasks";
/**
* A presentational CI glyph — not PRTaskIcon/MRTaskIcon. Those are Tooltip
* roots, and nesting one inside hover-card content is the pattern
* apps/web/AGENTS.md warns against (a Tooltip can close and unmount its
* parent HoverCard before the pointer reaches it).
*/
function SubtaskCIGlyph({ taskId }: { taskId: string }) {
const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId]) ?? [];
const mrs = useTaskMRs(taskId);
if (prs.length > 0) {
return (
<IconGitPullRequest
aria-hidden="true"
className={cn("size-3.5 shrink-0", aggregatePRStatusColor(prs))}
/>
);
}
if (mrs.length > 0) {
return (
<IconGitMerge
aria-hidden="true"
className={cn("size-3.5 shrink-0", aggregateMRStatusColor(mrs))}
/>
);
}
return null;
}
export function TaskSubtaskRow({ subtask }: { subtask: TaskSubtask }) {
return (
<Link
href={linkToTask(subtask.id)}
data-testid={`task-subtask-row-${subtask.id}`}
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-1 hover:bg-accent"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
}}
>
{/* The state label names the icon, not the link: an aria-label on the
anchor would replace its accessible name, so every row would announce
as its lifecycle state with the subtask title never read out. */}
<span role="img" aria-label={formatTaskStateLabel(subtask.state)} className="inline-flex">
{getTaskStateIcon(subtask.state, "h-3.5 w-3.5 shrink-0")}
</span>
<SubtaskCIGlyph taskId={subtask.id} />
<span className="min-w-0 flex-1 text-pretty break-words text-xs leading-snug [overflow-wrap:anywhere]">
{subtask.title}
</span>
</Link>
);
}
@@ -0,0 +1,377 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskPR } from "@/lib/types/github";
import type { TaskMR } from "@/lib/types/gitlab";
import { TaskTitleHoverCard } from "./task-title-hover-card";
afterEach(() => cleanup());
const LONG_TITLE = "A very long parent task title that should render in full";
const FIRST_SUBTASK_TITLE = "First subtask";
const HOVER_CARD_TEST_ID = "task-title-hover-card";
function makeTask(overrides: Partial<KanbanState["tasks"][number]>): KanbanState["tasks"][number] {
return {
id: "id",
workflowId: "wf-1",
workflowStepId: "step-1",
title: "Task",
position: 0,
...overrides,
};
}
function makePR(overrides: Pick<TaskPR, "id" | "task_id">): TaskPR {
return {
owner: "o",
repo: "r",
pr_number: 1,
pr_url: "",
pr_title: "Fix",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open" as const,
review_state: "",
checks_state: "",
mergeable_state: "",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
checks_passing: 0,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
function makeMR(): TaskMR {
return {
id: "mr-1",
task_id: "child-1",
host: "https://gitlab.com",
project_path: "group/project",
mr_iid: 5,
mr_url: "",
mr_title: "Linked MR",
head_branch: "feature",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
reviewer_count: 0,
unapproved_reviewers: 0,
unresolved_discussions: 0,
created_at: "",
updated_at: "",
};
}
function renderCard(tasks: KanbanState["tasks"], title = LONG_TITLE) {
render(
<StateProvider initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks } }}>
<TaskTitleHoverCard taskId="parent-1" title={title}>
<span data-testid="trigger">{title}</span>
</TaskTitleHoverCard>
</StateProvider>,
);
}
// Keyboard activation opens the interactive preview and moves focus into it.
async function openCard() {
const trigger = screen.getByTestId("task-title-preview-trigger");
trigger.focus();
fireEvent.click(trigger, { detail: 0 });
await screen.findAllByTestId(HOVER_CARD_TEST_ID);
}
/** First (portal) match for a subtask row; Radix also force-mounts a copy. */
function subtaskRow(childId: string): HTMLElement {
return screen.getAllByTestId(`task-subtask-row-${childId}`)[0];
}
describe("TaskTitleHoverCard", () => {
it("AC12: shows the full title in bold (font-semibold) when opened", async () => {
renderCard([makeTask({ id: "parent-1" })]);
await openCard();
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
expect(card.textContent).toContain(LONG_TITLE);
const titleEl = card.querySelector(".font-semibold");
expect(titleEl?.textContent).toBe(LONG_TITLE);
});
it("AC13: renders one row per direct subtask with its full title", async () => {
renderCard([
makeTask({ id: "parent-1" }),
makeTask({
id: "child-1",
title: FIRST_SUBTASK_TITLE,
parentTaskId: "parent-1",
position: 1,
}),
makeTask({ id: "child-2", title: "Second subtask", parentTaskId: "parent-1", position: 2 }),
]);
await openCard();
expect(subtaskRow("child-1").textContent).toContain(FIRST_SUBTASK_TITLE);
expect(subtaskRow("child-2").textContent).toContain("Second subtask");
});
it("names the subtask link by its title, with the state label on the icon", async () => {
renderCard([
makeTask({ id: "parent-1" }),
makeTask({
id: "child-1",
title: FIRST_SUBTASK_TITLE,
parentTaskId: "parent-1",
position: 1,
state: "IN_PROGRESS",
}),
]);
await openCard();
const row = subtaskRow("child-1");
// An aria-label on the anchor would override its accessible name, so every
// row would announce as "In progress" with the title never read out.
expect(row.getAttribute("aria-label")).toBeNull();
expect(row.textContent).toContain(FIRST_SUBTASK_TITLE);
expect(row.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("In progress");
});
it("AC13: shows a CI glyph only for a subtask with a linked PR", async () => {
render(
<StateProvider
initialState={{
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [
makeTask({ id: "parent-1" }),
makeTask({
id: "child-1",
title: "Has a PR",
parentTaskId: "parent-1",
position: 1,
}),
makeTask({
id: "child-2",
title: "No PR",
parentTaskId: "parent-1",
position: 2,
}),
],
},
taskPRs: {
byTaskId: {
"child-1": [makePR({ id: "pr-1", task_id: "child-1" })],
},
},
}}
>
<TaskTitleHoverCard taskId="parent-1" title="Parent">
<span tabIndex={0} data-testid="trigger">
Parent
</span>
</TaskTitleHoverCard>
</StateProvider>,
);
await openCard();
const withPR = subtaskRow("child-1");
const withoutPR = subtaskRow("child-2");
expect(withPR.querySelector(".tabler-icon-git-pull-request")).not.toBeNull();
expect(withoutPR.querySelector(".tabler-icon-git-pull-request")).toBeNull();
expect(withoutPR.querySelector(".tabler-icon-git-merge")).toBeNull();
});
});
describe("TaskTitleHoverCard — GitLab MR subtasks", () => {
it("AC13: shows the merge glyph for a subtask whose only link is a GitLab MR", async () => {
render(
<StateProvider
initialState={{
workspaces: { items: [], activeId: "ws-1" },
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [
makeTask({ id: "parent-1" }),
makeTask({
id: "child-1",
title: "Has an MR",
parentTaskId: "parent-1",
position: 1,
}),
makeTask({ id: "child-2", title: "No link", parentTaskId: "parent-1", position: 2 }),
],
},
taskMRs: { byWorkspaceId: { "ws-1": { "child-1": [makeMR()] } } },
}}
>
<TaskTitleHoverCard taskId="parent-1" title="Parent">
<span tabIndex={0} data-testid="trigger">
Parent
</span>
</TaskTitleHoverCard>
</StateProvider>,
);
await openCard();
const withMR = subtaskRow("child-1");
const withoutMR = subtaskRow("child-2");
expect(withMR.querySelector(".tabler-icon-git-merge")).not.toBeNull();
expect(withMR.querySelector(".tabler-icon-git-pull-request")).toBeNull();
expect(withoutMR.querySelector(".tabler-icon-git-merge")).toBeNull();
});
});
describe("TaskTitleHoverCard — subtask cap and dismissal", () => {
it("AC15: opens with only the bold title when there are no subtasks", async () => {
renderCard([makeTask({ id: "parent-1" })]);
await openCard();
expect(screen.queryAllByTestId("task-title-hover-subtasks")).toHaveLength(0);
});
it("AC17: caps subtask rows at 12 and shows a +N more line", async () => {
const children = Array.from({ length: 15 }, (_, i) =>
makeTask({
id: `child-${i}`,
title: `Subtask ${i}`,
parentTaskId: "parent-1",
position: i,
}),
);
renderCard([makeTask({ id: "parent-1" }), ...children]);
await openCard();
for (let i = 0; i < 12; i++) {
expect(screen.getAllByTestId(`task-subtask-row-child-${i}`).length).toBeGreaterThan(0);
}
for (let i = 12; i < 15; i++) {
expect(screen.queryAllByTestId(`task-subtask-row-child-${i}`)).toHaveLength(0);
}
const subtasksSection = screen.getAllByTestId("task-title-hover-subtasks")[0];
expect(subtasksSection.textContent).toContain("3 more");
});
it("AC19: never renders a TooltipContent inside the card", async () => {
renderCard([
makeTask({ id: "parent-1" }),
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1", position: 1 }),
]);
await openCard();
expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
});
it("does not bubble a click on the title (non-link) area to the parent row's onClick", async () => {
const onParentClick = vi.fn();
render(
<StateProvider
initialState={{
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [makeTask({ id: "parent-1" })],
},
}}
>
<div onClick={onParentClick} data-testid="parent-row">
<TaskTitleHoverCard taskId="parent-1" title={LONG_TITLE}>
<span tabIndex={0} data-testid="trigger">
{LONG_TITLE}
</span>
</TaskTitleHoverCard>
</div>
</StateProvider>,
);
await openCard();
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
const titleEl = card.querySelector(".font-semibold");
expect(titleEl).not.toBeNull();
fireEvent.click(titleEl!);
expect(onParentClick).not.toHaveBeenCalled();
});
});
describe("TaskTitleHoverCard — keyboard propagation", () => {
it("lets Escape bubble from the trigger to a closed surface (e.g. a preview panel) when the preview itself is not open", () => {
// Regression: a plain click focuses the trigger button without opening
// the preview (handleTriggerClick early-returns for real clicks). If
// Escape were unconditionally swallowed here, closing an ancestor
// surface (like the kanban preview panel) via Escape would silently stop
// working the moment its title happened to be under focus.
const onParentKeyDown = vi.fn();
render(
<StateProvider
initialState={{
kanban: { workflowId: "wf-1", steps: [], tasks: [makeTask({ id: "parent-1" })] },
}}
>
<div onKeyDown={onParentKeyDown}>
<TaskTitleHoverCard taskId="parent-1" title={LONG_TITLE}>
<span data-testid="trigger">{LONG_TITLE}</span>
</TaskTitleHoverCard>
</div>
</StateProvider>,
);
const trigger = screen.getByTestId("task-title-preview-trigger");
trigger.focus();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(onParentKeyDown).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull();
});
it("does not bubble keyboard activation from a subtask link to the parent row", async () => {
const onParentKeyDown = vi.fn();
render(
<StateProvider
initialState={{
kanban: {
workflowId: "wf-1",
steps: [],
tasks: [
makeTask({ id: "parent-1" }),
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1" }),
],
},
}}
>
<div onKeyDown={onParentKeyDown}>
<TaskTitleHoverCard taskId="parent-1" title="Parent">
<span>Parent</span>
</TaskTitleHoverCard>
</div>
</StateProvider>,
);
await openCard();
const row = subtaskRow("child-1");
expect(document.activeElement).toBe(row);
row.focus();
fireEvent.keyDown(row, { key: "Enter" });
expect(onParentKeyDown).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,163 @@
"use client";
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 { useHoverPopover } from "@/components/integrations/use-hover-popover";
import { TaskSubtaskRow } from "./task-subtask-row";
const MAX_VISIBLE_SUBTASKS = 12;
const OPEN_DELAY_MS = 200;
const CLOSE_DELAY_MS = 100;
function SubtasksSection({ taskId }: { taskId: string }) {
const { t } = useTranslation();
const subtasks = useTaskSubtasks(taskId);
if (subtasks.length === 0) return null;
const visible = subtasks.slice(0, MAX_VISIBLE_SUBTASKS);
const overflowCount = subtasks.length - visible.length;
return (
<div
className="mt-2.5 border-t border-border/60 pt-2.5"
data-testid="task-title-hover-subtasks"
>
<div className="text-[11px] font-medium text-muted-foreground">
{t("task:subtasksHeading", { count: subtasks.length })}
</div>
<div className="mt-1.5 space-y-0.5">
{visible.map((subtask) => (
<TaskSubtaskRow key={subtask.id} subtask={subtask} />
))}
{overflowCount > 0 && (
<div className="px-1 py-1 text-xs text-muted-foreground">
{t("task:moreNotShown", { count: overflowCount })}
</div>
)}
</div>
</div>
);
}
function DesktopTaskTitlePreview({
taskId,
title,
children,
side,
align,
}: {
taskId: string;
title: string;
children: ReactNode;
side: "top" | "right" | "bottom" | "left";
align: "start" | "center" | "end";
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const keyboardSessionRef = useRef(false);
const hover = useHoverPopover({ openDelayMs: OPEN_DELAY_MS, closeDelayMs: CLOSE_DELAY_MS });
const handleTriggerClick = (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail !== 0) return;
event.preventDefault();
event.stopPropagation();
keyboardSessionRef.current = true;
hover.onOpenChange(true);
};
const handleContentBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) hover.onContentLeave();
};
return (
<Popover open={hover.open} onOpenChange={hover.onOpenChange}>
<PopoverAnchor asChild>
<button
ref={triggerRef}
type="button"
data-testid="task-title-preview-trigger"
aria-haspopup="dialog"
aria-expanded={hover.open}
onPointerEnter={hover.onTriggerEnter}
onPointerLeave={hover.onTriggerLeave}
onKeyDown={(event) => {
// 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}
</button>
</PopoverAnchor>
<PopoverContent
ref={contentRef}
side={side}
align={align}
data-testid="task-title-hover-card"
className="w-80 max-w-[calc(100vw-1rem)] max-h-80 overflow-y-auto p-3"
onPointerEnter={hover.onContentEnter}
onPointerLeave={() => {
if (!contentRef.current?.contains(document.activeElement)) hover.onContentLeave();
}}
onFocusCapture={hover.onContentEnter}
onBlurCapture={handleContentBlur}
onOpenAutoFocus={(event) => {
event.preventDefault();
if (keyboardSessionRef.current) {
contentRef.current
?.querySelector<HTMLElement>(
'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()}
>
<div className="text-pretty break-words text-sm font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
{title}
</div>
<SubtasksSection taskId={taskId} />
</PopoverContent>
</Popover>
);
}
/** 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 (
<DesktopTaskTitlePreview taskId={taskId} title={title} side={side} align={align}>
{children}
</DesktopTaskTitlePreview>
);
}
@@ -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("|");
});
});
@@ -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();
});
});
@@ -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");
});
});
@@ -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 <p> 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}`));
});
});
@@ -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
@@ -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);
@@ -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" }),
@@ -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<Page["
// the row is the only one with this className combo; locate via role.
const input = node.getByRole("textbox");
await expect(input).toBeVisible({ timeout: 2_000 });
// The rename input is focused in an effect after the row switches into
// edit mode; allow a little extra time when the browser is under CI load.
await expect(input).toBeFocused({ timeout: 5_000 });
await expect(input).toBeFocused();
return input;
}
@@ -0,0 +1,178 @@
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
/**
* 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.
*/
// 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";
async function seedParentWithSubtasks(apiClient: ApiClient, seedData: SeedData) {
await apiClient.saveUserSettings({
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, {
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, {
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 ({
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);
});
});
+4
View File
@@ -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",
@@ -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<string, TaskMR[]> }) => void = () => {};
const firstPromise = new Promise<{ task_mrs: Record<string, TaskMR[]> }>((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
+26 -6
View File
@@ -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<ReturnType<typeof listWorkspaceTaskMRs>>;
const workspaceMRRequests = new Map<string, Promise<WorkspaceMRResponse>>();
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
}
});
@@ -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]>): KanbanState["tasks"][number] {
return {
id: "id",
workflowId: "wf-1",
workflowStepId: "step-1",
title: "Task",
position: 0,
...overrides,
};
}
function renderWithState(initialState: Parameters<typeof StateProvider>[0]["initialState"]) {
return renderHook(() => useTaskSubtasks("parent-1"), {
wrapper: ({ children }) => (
<StateProvider initialState={initialState}>{children}</StateProvider>
),
});
}
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);
});
});
@@ -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<string, TaskSubtask>();
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],
);
}
+18 -4
View File
@@ -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}}",
+2
View File
@@ -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",
+18 -4
View File
@@ -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}}",
+2
View File
@@ -1420,6 +1420,8 @@
"submitUnavailable": "Śũƀḿĩţ ũńàvàĩĺàƀĺē",
"submitting": "Śũƀḿĩţţĩńĝ…",
"subtask": "Śũƀţàśķ",
"subtasksHeading_one": "{{count}} śũƀţàśķ",
"subtasksHeading_other": "{{count}} śũƀţàśķś",
"subtasksBecomeRootTasksUnlessYou": "Śũƀţàśķś ƀēćōḿē ŕōōţ ţàśķś ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
"subtasksStayActiveUnlessYouTick": "Śũƀţàśķś śţàŷ àćţĩvē ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
"successful": "{{operationName}} śũććēśśƒũĺ",
+18 -4
View File
@@ -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}}",
+2
View File
@@ -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",
+18 -4
View File
@@ -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}}",
+2
View File
@@ -1374,6 +1374,8 @@
"submitUnavailable": "无法提交",
"submitting": "正在提交…",
"subtask": "子任务",
"subtasksHeading_one": "{{count}} 个子任务",
"subtasksHeading_other": "{{count}} 个子任务",
"subtasksBecomeRootTasksUnlessYou": "除非勾选此项,否则子任务将成为根任务。它们可能仍在进行中。",
"subtasksStayActiveUnlessYouTick": "除非勾选此项,否则子任务保持活动。它们可能仍在进行中。",
"successful": "{{operationName}} 成功",
+18 -4
View File
@@ -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}}",
+2
View File
@@ -1374,6 +1374,8 @@
"submitUnavailable": "無法提交",
"submitting": "正在提交…",
"subtask": "子任務",
"subtasksHeading_one": "{{count}} 個子任務",
"subtasksHeading_other": "{{count}} 個子任務",
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
"successful": "{{operationName}} 成功",
+18 -4
View File
@@ -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}}",
+2
View File
@@ -1374,6 +1374,8 @@
"submitUnavailable": "無法提交",
"submitting": "正在提交…",
"subtask": "子任務",
"subtasksHeading_one": "{{count}} 個子任務",
"subtasksHeading_other": "{{count}} 個子任務",
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
"successful": "{{operationName}} 成功",
@@ -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
@@ -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)
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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 |
@@ -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
@@ -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).