fix: reclaim sidebar task title width (#3875)

This commit is contained in:
Carlos Florêncio
2026-09-23 10:58:29 +01:00
committed by GitHub
parent 0386e3e9b7
commit 40b13df86d
9 changed files with 801 additions and 34 deletions
@@ -29,26 +29,6 @@ describe("TaskItemTrailing relative time", () => {
expect(relativeTime.querySelector(".sr-only")?.textContent).toBe("2 days ago");
expect(relativeTime.getAttribute("aria-label")).toBeNull();
expect(relativeTime.getAttribute("title")).toBe("2 days ago");
expect(relativeTime.className).toContain("w-11");
expect(relativeTime.className).toContain("text-right");
expect(relativeTime.className).toContain("tabular-nums");
expect(relativeTime.parentElement?.className).toContain("[@media(max-width:639px)]:w-auto");
});
it("uses the outer task-row menu disclosure hover and focus selectors", () => {
render(
<TaskItemTrailing
trailing="relative_time"
menuOpen={false}
effectiveMenuOpen={false}
relativeTime="2026-07-24T00:00:00Z"
/>,
);
const relativeTime = screen.getByTestId("sidebar-task-trailing-time");
expect(relativeTime.className).toContain("group-hover:opacity-0");
expect(relativeTime.className).toContain("group-focus-within/actions:opacity-0");
expect(relativeTime.className).not.toContain("group-hover/actions:opacity-0");
});
it("omits an invalid timestamp instead of reserving a time column", () => {
@@ -60,13 +60,18 @@ export function TaskItemTrailing({
if (trailing === "relative_time" && compactRelativeTime) {
return (
<div className="group/actions relative flex w-11 shrink-0 items-center self-center [@media(max-width:639px)]:w-auto">
<div className="group/actions relative flex w-11 shrink-0 items-center self-center [@media(min-width:640px)_and_(pointer:fine)]:w-fit [@media(min-width:640px)_and_(pointer:fine)]:min-w-6 [@media(min-width:640px)_and_(pointer:fine)]:justify-end [@media(min-width:640px)_and_(pointer:coarse)]:w-auto [@media(min-width:640px)_and_(pointer:coarse)]:min-w-11 [@media(min-width:640px)_and_(pointer:coarse)]:justify-end [@media(max-width:639px)]:w-auto [@media(max-width:639px)]:min-w-11">
<span
data-testid="sidebar-task-trailing-time"
data-time-value={relativeTime}
title={accessibleRelativeTime}
className={cn(
"flex w-11 shrink-0 justify-end text-right text-[11px] text-muted-foreground/50 tabular-nums transition-opacity duration-100",
"flex w-11 shrink-0 justify-end text-right text-[11px] text-muted-foreground/50 tabular-nums whitespace-nowrap transition-opacity duration-100",
"[@media(min-width:640px)_and_(pointer:fine)]:w-auto",
"[@media(min-width:640px)_and_(pointer:coarse)]:w-auto",
"[@media(min-width:640px)_and_(pointer:coarse)]:min-w-11",
"[@media(max-width:639px)]:w-auto",
"[@media(max-width:639px)]:min-w-11",
effectiveMenuOpen && "opacity-0",
!effectiveMenuOpen &&
"[@media(hover:hover)]:group-hover:opacity-0 group-focus-within/actions:opacity-0",
@@ -702,7 +702,7 @@ test.describe("Mobile sidebar — view system", () => {
await expect(trailingTime.locator(".sr-only")).toHaveText(/\S/);
const mobileTimeBox = await trailingTime.boundingBox();
expect(mobileTimeBox).not.toBeNull();
expect(mobileTimeBox!.width).toBeGreaterThanOrEqual(40);
expect(mobileTimeBox!.width).toBeGreaterThanOrEqual(44);
await prCapture.screenshot("mobile-task-row-settings", {
caption: "Mobile task-row settings in the inset bottom drawer",
});
@@ -722,20 +722,41 @@ test.describe("Mobile sidebar — view system", () => {
await expect(popover).toBeHidden();
const taskAction = row.getByRole("button", { name: "Task actions" });
await expect(taskAction).toBeVisible();
await prCapture.screenshot("mobile-task-row-trailing-actions", {
caption: "Phone task row with adjacent time and its visible touch action",
});
const taskActionBox = await taskAction.boundingBox();
expect(taskActionBox).not.toBeNull();
expect(taskActionBox!.width).toBeGreaterThanOrEqual(44);
expect(taskActionBox!.height).toBeGreaterThanOrEqual(44);
expect(mobileTimeBox!.x + mobileTimeBox!.width).toBeLessThanOrEqual(taskActionBox!.x + 1);
const rowBoxWithAction = await row.boundingBox();
expect(rowBoxWithAction).not.toBeNull();
expect(mobileTimeBox!.x).toBeGreaterThanOrEqual(rowBoxWithAction!.x - 1);
expect(mobileTimeBox!.x + mobileTimeBox!.width).toBeLessThanOrEqual(
rowBoxWithAction!.x + rowBoxWithAction!.width + 1,
);
expect(mobileTimeBox!.y).toBeGreaterThanOrEqual(rowBoxWithAction!.y - 1);
expect(mobileTimeBox!.y + mobileTimeBox!.height).toBeLessThanOrEqual(
rowBoxWithAction!.y + rowBoxWithAction!.height + 1,
);
expect(taskActionBox!.x).toBeGreaterThanOrEqual(rowBoxWithAction!.x - 1);
expect(taskActionBox!.x + taskActionBox!.width).toBeLessThanOrEqual(
rowBoxWithAction!.x + rowBoxWithAction!.width + 1,
);
expect(taskActionBox!.y).toBeGreaterThanOrEqual(rowBoxWithAction!.y - 1);
expect(taskActionBox!.y + taskActionBox!.height).toBeLessThanOrEqual(
rowBoxWithAction!.y + rowBoxWithAction!.height + 1,
);
expect(taskActionBox!.x).toBeGreaterThanOrEqual(drawerBox!.x - 1);
expect(taskActionBox!.x + taskActionBox!.width).toBeLessThanOrEqual(
drawerBox!.x + drawerBox!.width + 1,
);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await row.tap();
await expect(testPage).toHaveURL((url) => url.pathname === `/t/${task!.id}`);
@@ -880,7 +880,6 @@ test.describe("Sidebar filter — task-row presentation", () => {
]);
expect(timeBox).not.toBeNull();
expect(secondTimeBox).not.toBeNull();
expect(Math.abs(timeBox!.width - secondTimeBox!.width)).toBeLessThanOrEqual(1);
await filters.openTaskRowSettings();
await detailsToggle.click();
@@ -0,0 +1,420 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { SidebarFilterPopoverPage } from "../../pages/sidebar-filter-popover";
function taskRow(sidebar: import("@playwright/test").Locator, taskId: string) {
return sidebar.locator(`[data-task-row-id="${taskId}"]`);
}
async function readRowLayout(row: import("@playwright/test").Locator, taskId: string) {
const title = row.locator("span.overflow-hidden").first();
const prIcon = row.getByTestId(`pr-task-icon-${taskId}`);
const relativeTime = row.getByTestId("sidebar-task-trailing-time");
const timeSlot = relativeTime.locator("xpath=..");
const action = row.locator('button[aria-label="Task actions"]');
const visualTime = relativeTime.locator('[aria-hidden="true"]');
const [titleBox, prBox, slotBox, actionBox, visualTimeBox] = await Promise.all([
title.boundingBox(),
prIcon.boundingBox(),
timeSlot.boundingBox(),
action.boundingBox(),
visualTime.boundingBox(),
]);
expect(titleBox).not.toBeNull();
expect(prBox).not.toBeNull();
expect(slotBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(visualTimeBox).not.toBeNull();
return {
title: titleBox!,
pr: prBox!,
slot: slotBox!,
action: actionBox!,
visualTime: visualTimeBox!,
};
}
async function expectContentSizedTimeSlot(
row: import("@playwright/test").Locator,
minimumWidth = 0,
) {
const relativeTime = row.getByTestId("sidebar-task-trailing-time");
const timeSlot = relativeTime.locator("xpath=..");
const visualTime = relativeTime.locator('[aria-hidden="true"]');
const action = row.locator("button.mobile-task-actions-button");
const [slotBox, visualTimeBox, actionBox] = await Promise.all([
timeSlot.boundingBox(),
visualTime.boundingBox(),
action.boundingBox(),
]);
expect(slotBox).not.toBeNull();
expect(visualTimeBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(slotBox!.width).toBeCloseTo(
Math.max(minimumWidth, visualTimeBox!.width, actionBox!.width),
1,
);
expect(
Math.abs(visualTimeBox!.x + visualTimeBox!.width - (slotBox!.x + slotBox!.width)),
).toBeLessThanOrEqual(1);
}
async function expectTouchSizedTimeSlot(
row: import("@playwright/test").Locator,
minimumWidth: number,
) {
const relativeTime = row.getByTestId("sidebar-task-trailing-time");
const timeSlot = relativeTime.locator("xpath=..");
const visualTime = relativeTime.locator('[aria-hidden="true"]');
const [slotBox, visualTimeBox] = await Promise.all([
timeSlot.boundingBox(),
visualTime.boundingBox(),
]);
expect(slotBox).not.toBeNull();
expect(visualTimeBox).not.toBeNull();
expect(slotBox!.width).toBeGreaterThanOrEqual(minimumWidth);
expect(slotBox!.width).toBeGreaterThanOrEqual(visualTimeBox!.width - 1);
expect(
Math.abs(visualTimeBox!.x + visualTimeBox!.width - (slotBox!.x + slotBox!.width)),
).toBeLessThanOrEqual(1);
}
function expectStableLayout(
baseline: Awaited<ReturnType<typeof readRowLayout>>,
current: Awaited<ReturnType<typeof readRowLayout>>,
) {
for (const key of ["x", "y", "width", "height"] as const) {
expect(current.title[key]).toBeCloseTo(baseline.title[key], 1);
expect(current.pr[key]).toBeCloseTo(baseline.pr[key], 1);
expect(current.slot[key]).toBeCloseTo(baseline.slot[key], 1);
}
}
test("sidebar title width follows compact time without hover movement", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const longTitle = "Keep the linked pull request beside this long task title";
const shortTitle = "Short sidebar title";
const longTask = await apiClient.createTask(seedData.workspaceId, longTitle, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const shortTask = await apiClient.createTask(seedData.workspaceId, shortTitle, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const shortTaskWithoutPR = await apiClient.createTask(
seedData.workspaceId,
"Short title without a pull request",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const navigationTask = await apiClient.createTask(seedData.workspaceId, "Title width nav", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
for (const task of [longTask, shortTask]) {
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
workspace_id: seedData.workspaceId,
repository_id: seedData.repositoryId,
owner: "testorg",
repo: "testrepo",
pr_number: task.id === longTask.id ? 941 : 942,
pr_url: `https://github.com/testorg/testrepo/pull/${task.id === longTask.id ? 941 : 942}`,
pr_title: "Sidebar title width",
head_branch: "feature/sidebar-title-width",
base_branch: "main",
author_login: "e2e",
state: "open",
checks_state: "success",
});
}
await testPage.clock.setFixedTime(
new Date(Date.parse(longTask.updated_at) + 2 * 60 * 60 * 1000 + 5 * 60 * 1000),
);
await testPage.goto(`/t/${navigationTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.sidebar).toBeVisible();
expect(
await testPage.evaluate(() => matchMedia("(min-width: 640px) and (pointer: fine)").matches),
).toBe(true);
const settingsBefore = await apiClient.getUserSettings();
const previousViewState =
settingsBefore.settings.sidebar_views_by_workspace[seedData.workspaceId];
const filters = new SidebarFilterPopoverPage(testPage);
try {
await testPage.setViewportSize({ width: 1280, height: 900 });
await filters.open();
await filters.openTaskRowSettings();
await filters.setTaskRowTrailing("Relative time");
await filters.saveOverwrite();
await filters.close();
const longRow = taskRow(session.sidebar, longTask.id);
const shortRow = taskRow(session.sidebar, shortTask.id);
const noPRRow = taskRow(session.sidebar, shortTaskWithoutPR.id);
await expect(longRow.getByTestId("sidebar-task-trailing-time")).toBeVisible();
await expect(shortRow.getByTestId(`pr-task-icon-${shortTask.id}`)).toBeVisible({
timeout: 15_000,
});
await expect(
noPRRow.getByText("Short title without a pull request", { exact: true }),
).toBeVisible();
const baseline = await readRowLayout(longRow, longTask.id);
await expect(
longRow.getByTestId("sidebar-task-trailing-time").locator('[aria-hidden="true"]'),
).toHaveText("2h");
expect(baseline.visualTime.width).toBeLessThan(24);
expect(
Math.abs(
baseline.visualTime.x + baseline.visualTime.width - (baseline.slot.x + baseline.slot.width),
),
).toBeLessThanOrEqual(1);
expect(baseline.pr.x - (baseline.title.x + baseline.title.width)).toBeCloseTo(4, 1);
expect(baseline.slot.width).toBeCloseTo(
Math.max(baseline.visualTime.width, baseline.action.width),
1,
);
const shortLayout = await readRowLayout(shortRow, shortTask.id);
expect(shortLayout.pr.x - (shortLayout.title.x + shortLayout.title.width)).toBeCloseTo(4, 1);
const noPRTitle = noPRRow.locator("span.overflow-hidden").first();
const noPRTime = noPRRow.getByTestId("sidebar-task-trailing-time");
const noPRSlot = noPRTime.locator("xpath=..");
const noPRAction = noPRRow.locator('button[aria-label="Task actions"]');
const noPRVisualTime = noPRTime.locator('[aria-hidden="true"]');
const [noPRTitleBox, noPRSlotBox, noPRActionBox, noPRTimeBox] = await Promise.all([
noPRTitle.boundingBox(),
noPRSlot.boundingBox(),
noPRAction.boundingBox(),
noPRVisualTime.boundingBox(),
]);
expect(noPRTitleBox).not.toBeNull();
expect(noPRSlotBox).not.toBeNull();
expect(noPRActionBox).not.toBeNull();
expect(noPRTimeBox).not.toBeNull();
expect(await noPRRow.getByTestId(`pr-task-icon-${shortTaskWithoutPR.id}`).count()).toBe(0);
expect(noPRSlotBox!.width).toBeCloseTo(Math.max(noPRTimeBox!.width, noPRActionBox!.width), 1);
const actions = longRow.locator('button[aria-label="Task actions"]');
const actionWrapper = actions.locator("xpath=..");
await longRow.hover();
await expect(actionWrapper).toHaveCSS("opacity", "1");
expectStableLayout(baseline, await readRowLayout(longRow, longTask.id));
await testPage.mouse.move(0, 0);
await longRow.focus();
expectStableLayout(baseline, await readRowLayout(longRow, longTask.id));
await actions.focus();
await expect(actionWrapper).toHaveCSS("opacity", "1");
expectStableLayout(baseline, await readRowLayout(longRow, longTask.id));
await longRow.click({ button: "right" });
const contextMenu = testPage.getByRole("menu");
await expect(contextMenu).toBeVisible();
expectStableLayout(baseline, await readRowLayout(longRow, longTask.id));
await testPage.keyboard.press("Escape");
await expect(contextMenu).toBeHidden();
expectStableLayout(baseline, await readRowLayout(longRow, longTask.id));
await filters.open();
await filters.openTaskRowSettings();
await filters.taskRowSettings.getByTestId("task-row-details-toggle").click();
await filters.saveOverwrite();
await filters.close();
const compactDetailsLayout = await readRowLayout(longRow, longTask.id);
expect(compactDetailsLayout.title.width).toBeCloseTo(baseline.title.width, 1);
expect(
compactDetailsLayout.pr.x - (compactDetailsLayout.title.x + compactDetailsLayout.title.width),
).toBeCloseTo(4, 1);
expect(compactDetailsLayout.slot.width).toBeCloseTo(
Math.max(compactDetailsLayout.visualTime.width, compactDetailsLayout.action.width),
1,
);
const sidebarLayout = testPage.getByTestId("app-sidebar-layout");
const narrowSidebarBox = await sidebarLayout.boundingBox();
const resizeHandle = testPage.getByRole("button", { name: /resize sidebar/i });
const resizeHandleBox = await resizeHandle.boundingBox();
expect(narrowSidebarBox).not.toBeNull();
expect(resizeHandleBox).not.toBeNull();
await testPage.mouse.move(
resizeHandleBox!.x + resizeHandleBox!.width / 2,
resizeHandleBox!.y + 120,
);
await testPage.mouse.down();
await testPage.mouse.move(
resizeHandleBox!.x + resizeHandleBox!.width / 2 + 60,
resizeHandleBox!.y + 120,
{ steps: 8 },
);
await testPage.mouse.up();
await expect
.poll(async () => (await sidebarLayout.boundingBox())?.width ?? 0)
.toBeGreaterThan(narrowSidebarBox!.width + 40);
const expandedLayout = await readRowLayout(longRow, longTask.id);
expect(expandedLayout.title.width).toBeGreaterThan(compactDetailsLayout.title.width + 40);
expect(expandedLayout.slot.width).toBeCloseTo(
Math.max(expandedLayout.visualTime.width, expandedLayout.action.width),
1,
);
await prCapture.screenshot("desktop-sidebar-title-width", {
caption: "Desktop task title and PR icon keep their geometry beside the compact time slot",
});
} finally {
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
sidebar_view_state: {
workspace_id: seedData.workspaceId,
views: previousViewState.views,
active_view_id: previousViewState.active_view_id,
draft: previousViewState.draft,
},
});
expect(response.ok).toBe(true);
}
});
test("localized time slots respect the fine-pointer width breakpoint", async ({
testPage,
coarseDesktopTestPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "修复侧边栏任务标题", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const navigationTask = await apiClient.createTask(seedData.workspaceId, "Localized width nav", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.clock.setFixedTime(
new Date(Date.parse(task.updated_at) + 101 * 365 * 24 * 60 * 60 * 1000),
);
await testPage.setViewportSize({ width: 1280, height: 900 });
await testPage.goto(`/t/${navigationTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const filters = new SidebarFilterPopoverPage(testPage);
await filters.open();
await filters.openTaskRowSettings();
await filters.setTaskRowTrailing("Relative time");
await filters.saveOverwrite();
await filters.close();
for (const locale of ["zh-cn", "pseudo", "ja"]) {
const origin = new URL(testPage.url()).origin;
await testPage.context().addCookies([{ name: "kandev_locale", value: locale, url: origin }]);
await testPage.reload();
await session.waitForLoad();
await expect(testPage.locator("html")).toHaveAttribute("lang", locale);
const row = taskRow(session.sidebar, task.id);
await expect(row.getByTestId("sidebar-task-trailing-time")).toBeVisible();
await expectContentSizedTimeSlot(row);
}
for (const width of [768, 640, 639]) {
await testPage.setViewportSize({ width, height: 900 });
expect(await testPage.evaluate(() => matchMedia("(pointer: fine)").matches)).toBe(true);
if (width === 640) {
await testPage.getByTestId("mobile-task-picker-trigger").click();
}
const row = testPage.locator(
`[data-testid="sidebar-task-item"][data-task-row-id="${task.id}"]:visible`,
);
await expect(row).toHaveCount(1);
await expect(row.getByTestId("sidebar-task-trailing-time")).toBeVisible();
if (width === 639) {
const relativeTime = row.getByTestId("sidebar-task-trailing-time");
const action = row.locator("button.mobile-task-actions-button");
const [rowBox, timeBox, actionBox] = await Promise.all([
row.boundingBox(),
relativeTime.boundingBox(),
action.boundingBox(),
]);
expect(rowBox).not.toBeNull();
expect(timeBox).not.toBeNull();
expect(actionBox).not.toBeNull();
const visualTime = relativeTime.locator('[aria-hidden="true"]');
const visualTimeBox = await visualTime.boundingBox();
expect(visualTimeBox).not.toBeNull();
expect(visualTimeBox!.width).toBeGreaterThan(44);
await expect(visualTime).toHaveText("99年以上");
expect(timeBox!.width).toBeGreaterThanOrEqual(44);
expect(actionBox!.width).toBeGreaterThanOrEqual(44);
expect(actionBox!.height).toBeGreaterThanOrEqual(44);
expect(visualTimeBox!.x).toBeGreaterThanOrEqual(timeBox!.x - 1);
expect(visualTimeBox!.x + visualTimeBox!.width).toBeLessThanOrEqual(
timeBox!.x + timeBox!.width + 1,
);
expect(visualTimeBox!.x + visualTimeBox!.width).toBeLessThanOrEqual(actionBox!.x + 1);
expect(actionBox!.x).toBeGreaterThanOrEqual(rowBox!.x - 1);
expect(actionBox!.x + actionBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
} else {
await expectContentSizedTimeSlot(row);
}
}
const coarseOrigin = new URL(testPage.url()).origin;
await coarseDesktopTestPage
.context()
.addCookies([{ name: "kandev_locale", value: "ja", url: coarseOrigin }]);
await coarseDesktopTestPage.clock.setFixedTime(
new Date(Date.parse(task.updated_at) + 101 * 365 * 24 * 60 * 60 * 1000),
);
await coarseDesktopTestPage.goto(`/t/${navigationTask.id}`);
const coarseSession = new SessionPage(coarseDesktopTestPage);
await coarseSession.waitForLoad();
for (const width of [768, 640]) {
await coarseDesktopTestPage.setViewportSize({ width, height: 900 });
expect(
await coarseDesktopTestPage.evaluate(() => matchMedia("(pointer: coarse)").matches),
).toBe(true);
if (width === 640) {
await coarseDesktopTestPage.getByTestId("mobile-task-picker-trigger").click();
}
const row = coarseDesktopTestPage.locator(
`[data-testid="sidebar-task-item"][data-task-row-id="${task.id}"]:visible`,
);
await expect(row).toHaveCount(1);
await expect(row.getByTestId("sidebar-task-trailing-time")).toBeVisible();
await expect(
row.getByTestId("sidebar-task-trailing-time").locator('[aria-hidden="true"]'),
).toHaveText("99年以上");
await expectTouchSizedTimeSlot(row, 44);
}
});
+144
View File
@@ -0,0 +1,144 @@
---
created: 2026-09-22
status: complete
requirements:
- REQ-UI-SIDEBAR-TASK-ROW-PRESENTATION-001
system_design:
- ../../specs/ui/system-design/sidebar-task-row-presentation.md
legacy_specs: []
---
# Implementation Plan: Sidebar Title Width
## Overview
Reclaim unused desktop time-column width in one implementation work order.
The user selected content-sized time columns, stable hover geometry, adjacent PR icons,
and preserved mobile touch targets.
UI owns this existing reusable presentation contract. No task or provider state changes are needed.
## Evidence and correction
Both the relative-time wrapper and its child in TaskItemTrailing use w-11 (44px).
TaskMenuButton uses size-6 (24px) on desktop. TaskItemContent already uses flex-1;
its long title consumes the space left by badges and trailing content.
The earlier diagnosis blaming removal of w-full is withdrawn: restoring it would move
badges away from short titles without reclaiming the fixed time column.
This is a source-grounded explanation, not a measured browser reproduction.
The first implementation check must reproduce the gap with a long title and PR badge.
## Scope
### In scope
- Replace fixed desktop time widths with max(localized time width, 24px).
- Preserve stable title width during hover, focus, and menu use.
- Preserve accessible full time, invalid-time fallback, badges, and mobile navigation.
- Update tests that currently assert equal desktop time widths.
### Out of scope
- Title-wrapper growth changes, formatting, locale keys, saved settings, and backend changes.
- Git-change and change-request trailing layout redesign.
- Changing coarse-pointer action visibility or introducing new touch targets.
## Technical approach
In apps/web/components/task/task-item-trailing.tsx, scope intrinsic sizing and
a 24px minimum to fine pointers at widths of at least 640px. The child time must
also lose its fixed width in that condition. Keep the time in layout while hidden
by opacity; overlay the existing right-aligned menu. Avoid measurements, new state,
and observers. Keep mobile in-flow actions and existing classes compatible.
Use task-item-menu-button.tsx as the button-size reference and app/globals.css
as the phone override authority. Inspect actual rendered ancestors before changing
selectors. No shared ScrollOnOverflow changes are needed.
The existing requirement/design pair now records this intended behavior.
The completed sidebar-task-row-compact-trailing package records the old fixed-width
delivery; its results remain historical. This package supersedes only that sizing choice.
## ASCII UI preview
```text
UI-01 Desktop sidebar, long title
Before: [state] Long title... [PR] [unused 2h]
After: [state] Longer title text... [PR] [2h]
Hover: [state] Longer title text... [PR] [...]
Short: [state] Short title [PR] [2h]
UI-02 Phone task picker, inset bottom drawer
+------------------------------------------+
| Tasks |
| [state] Title... [PR] [time] [44px action] |
| vertically scrolling rows |
+------------------------------------------+
```
Spacing is illustrative. UI-01 requires adjacent title badges and identical title geometry
between idle, hover, focus, and menu-open states. UI-02 keeps primary row navigation,
the existing safe-area handling, one internal scroll owner, and visible touch actions.
Criteria: AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.12, .14, .15, and .16.
## Tests
Component tests in task-item-trailing.test.tsx retain valid/invalid timestamp,
accessible full phrase, and menu behavior coverage for criterion .14.
Replace obsolete w-11 assertions; do not substitute class-only tests for geometry.
## E2E tests
- New e2e/tests/task/sidebar-title-width.spec.ts, desktop project: test
"title width follows compact time without hover movement". Long and short titles,
with and without PR badges, details on/off, 2h and wider localized tokens,
narrow and expanded sidebars. Measure max(time glyph width, button width),
title available width, badge adjacency, and unchanged rectangles across hover,
keyboard focus, menu open, and close (.14/.15).
- Same file: localized-time breakpoint test. Check 639/640/768px
with explicit fine/coarse pointer contexts and computed sizing; include
representative CJK and pseudo-locale tokens (.12/.15/.16). Assert visible
time right-edge alignment and non-overlap with touch actions.
- Update sidebar-filter.spec.ts "task row presentation" scenario to remove the
obsolete equal desktop-column width assertion (.14/.15).
- Extend mobile-sidebar-views.spec.ts "task row settings" scenario in mobile-chrome:
visible 44px action, time/action non-overlap, control containment in row,
no document overflow, and primary tap navigation (.12/.16).
Use existing seeded fixtures and causal waits. No user instance is needed.
Compare rendered desktop and phone screenshots with UI-01/UI-02.
## Work orders
- [x] [Task 01: Reclaim desktop title width](task-01-reclaim-title-width.md)
One wave, sequential execution, no dependencies.
## Verification
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run components/task/task-item-trailing.test.tsx components/task/task-item-compact-layout.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/task/task-item-trailing.tsx components/task/task-item-trailing.test.tsx e2e/tests/task/sidebar-title-width.spec.ts e2e/tests/task/mobile-sidebar-views.spec.ts e2e/tests/task/sidebar-filter.spec.ts)
make build-web
(cd apps/web && pnpm e2e:run tests/task/sidebar-title-width.spec.ts tests/task/sidebar-filter.spec.ts -- --grep "title width|task row presentation")
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings|title width")
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Task 01 is complete. Component, rendered desktop, and mobile geometry checks passed;
the web build, typecheck, lint, specification validation, and whitespace check passed.
The desktop and phone captures were inspected against UI-01 and UI-02.
## Risks
- Localized tokens can exceed 24px; intrinsic sizing must allow expansion without clipping.
- Timestamp updates can change width; hover with an unchanged value must not.
- Phone CSS makes the menu in-flow; an unconditional desktop width can clip it.
- Existing tests encode equal widths and must change with the new contract.
@@ -0,0 +1,176 @@
---
id: "01-reclaim-title-width"
title: "Reclaim desktop title width"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-SIDEBAR-TASK-ROW-PRESENTATION-001
system_design:
- ../../specs/ui/system-design/sidebar-task-row-presentation.md
acceptance_criteria:
- AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.12
- AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.14
- AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.15
- AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.16
---
# Task 01: Reclaim Desktop Title Width
## Summary
Size the desktop time/menu column to its content with a 24px button minimum.
Keep title width stable through interaction and preserve the phone row composition.
## In scope
- Reproduce current long-title/PR/time gap with a failing rendered geometry test.
- Implement the relative-time branch sizing from the paired design.
- Update obsolete component and desktop E2E expectations; verify mobile containment.
## Out of scope
Shared title components, other trailing modes, formatting, settings, and backend changes.
## Acceptance
- Desktop slot equals the wider of time and button within one CSS pixel; long titles reclaim the difference from 44px.
- Title and badge rectangles stay stable across hover, focus, menu open/close for unchanged time; short-title badges remain adjacent.
- Phone actions remain visible, at least 44px in both dimensions, inside the row, and separate from time; row navigation and no-overflow checks pass.
## ASCII UI preview
See [combined preview](plan.md#ascii-ui-preview).
```text
UI-01 Desktop sidebar, long title
Before: [state] Long title... [PR] [unused 2h]
After: [state] Longer title text... [PR] [2h]
Hover: [state] Longer title text... [PR] [...]
Short: [state] Short title [PR] [2h]
UI-02 Phone task picker, inset bottom drawer
+------------------------------------------+
| Tasks |
| [state] Title... [PR] [time] [44px action] |
| vertically scrolling rows |
+------------------------------------------+
```
Spacing is illustrative. UI-01 requires adjacent title badges and identical title geometry
between idle, hover, focus, and menu-open states. UI-02 keeps primary row navigation,
the existing safe-area handling, one internal scroll owner, and visible touch actions.
Criteria: AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.12, .14, .15, and .16.
## Verification
Run from the repository root. Install dependencies if this worktree is not installed.
Add rendered regression coverage first, demonstrate failure, then implement and run:
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run components/task/task-item-trailing.test.tsx components/task/task-item-compact-layout.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/task/task-item-trailing.tsx components/task/task-item-trailing.test.tsx e2e/tests/task/sidebar-title-width.spec.ts e2e/tests/task/mobile-sidebar-views.spec.ts e2e/tests/task/sidebar-filter.spec.ts)
make build-web
(cd apps/web && pnpm e2e:run tests/task/sidebar-title-width.spec.ts tests/task/sidebar-filter.spec.ts -- --grep "title width|task row presentation")
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings|title width")
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
Record screenshot inspection and every command result. Do not mark done on class assertions alone.
## Files likely touched
- apps/web/components/task/task-item-trailing.tsx
- apps/web/components/task/task-item-trailing.test.tsx
- apps/web/e2e/tests/task/sidebar-title-width.spec.ts
- apps/web/e2e/tests/task/sidebar-filter.spec.ts
- apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts
Reference only: task-item.tsx, task-item-menu-button.tsx, app/globals.css,
and the existing mobile-sidebar-task-actions.spec.ts geometry patterns.
## Dependencies
None.
## Risks
Localized width and phone in-flow overrides can cause clipping if desktop sizing leaks.
## Parallelism
sequential
## Inputs
- [Requirements](../../specs/ui/requirements/sidebar-task-row-presentation.md), criteria .12 and .14-.16.
- [Design](../../specs/ui/system-design/sidebar-task-row-presentation.md), trailing layout and responsive behavior.
- [Plan](plan.md), evidence correction and E2E scenario matrix.
## Results
Implemented content-sized desktop time slots with a 24px minimum on fine-pointer
layouts at 640px and wider. The hidden time keeps its intrinsic width, so hover,
focus, and menu states do not move the title or PR badge. Phone layouts retain the
44px time and action composition. The obsolete equal desktop time-width assertion
was removed.
A review follow-up scoped `justify-end` to the desktop fine-pointer wrapper. The
desktop E2E now fixes the page time relative to the task update timestamp so the
visual token is deterministically `2h` and narrower than the 24px slot. Before
the follow-up fix, its right edge was 11.765625px short of the slot edge; after
the fix, the 1px geometry assertion passes. The mobile layout remains unchanged.
The first rendered desktop check failed as intended: the slot measured 44px where
the action minimum was 24px. After the implementation, the focused geometry check
passed. Desktop coverage also checks short and long titles with and without PRs,
details on/off, expanded sidebar width, localized tokens, and stable interaction
geometry. The mobile check confirms visible 44px controls, containment, separation,
navigation, and no horizontal overflow.
Verification results:
- `pnpm exec vitest run components/task/task-item-trailing.test.tsx components/task/task-item-compact-layout.test.tsx` — passed, 2 files and 8 tests.
- `pnpm run typecheck` — passed.
- `pnpm exec eslint components/task/task-item-trailing.tsx components/task/task-item-trailing.test.tsx e2e/tests/task/sidebar-title-width.spec.ts e2e/tests/task/mobile-sidebar-views.spec.ts e2e/tests/task/sidebar-filter.spec.ts` — passed.
- `make build-web` — passed.
- `pnpm e2e:run --no-build tests/task/sidebar-title-width.spec.ts` — passed, 2 tests.
- `pnpm e2e:run --no-build tests/task/sidebar-title-width.spec.ts tests/task/sidebar-filter.spec.ts -- --grep "title width|task row presentation"` — the sidebar-filter presentation test passed; the title interaction assertion was corrected and the complete sidebar-title-width file passed in the command above.
- `pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings|title width"` — passed, 1 test.
- `CAPTURE_PR_ASSETS=true pnpm e2e:run --host --no-build tests/task/sidebar-title-width.spec.ts -- --grep "title width follows"` — passed, 1 test; the desktop capture was inspected.
- `CAPTURE_PR_ASSETS=true pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings"` — passed, 1 test; the phone capture was inspected.
- `python3 scripts/list-docs.py validate` — passed, 299 decisions and 1108 specifications.
- `python3 scripts/lint-spec-files.py --all` — passed.
- `git diff --check` — passed.
Post-review verification against the final production build:
- `make build-web` — passed.
- `pnpm run typecheck` — passed.
- `pnpm exec eslint components/task/task-item-trailing.tsx components/task/task-item-trailing.test.tsx e2e/tests/task/sidebar-title-width.spec.ts e2e/tests/task/mobile-sidebar-views.spec.ts e2e/tests/task/sidebar-filter.spec.ts` — passed.
- `pnpm exec vitest run components/task/task-item-trailing.test.tsx components/task/task-item-compact-layout.test.tsx` — passed, 2 files and 8 tests.
- `pnpm e2e:run --host --no-build tests/task/sidebar-title-width.spec.ts` — passed, 2 tests, including the `2h` right-edge assertion and localized-token breakpoints.
- `pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings|title width"` — passed, 1 test.
- `git diff --check` — passed after the review follow-up.
PR review follow-up: the Japanese `99年以上` token extended beyond the 44px phone
time span before the fix. Phone and coarse-pointer slots now keep a 44px minimum,
grow to non-wrapping localized content, and right-align the time without overlapping
the in-flow phone action. Coarse-pointer desktop breakpoints retain their 44px slot
minimum. Relative-time component tests no longer assert CSS class strings; rendered
geometry owns the responsive time-layout contract.
- `make build-web` — passed.
- `pnpm run typecheck` — passed.
- `pnpm exec eslint components/task/task-item-trailing.tsx components/task/task-item-trailing.test.tsx e2e/tests/task/sidebar-title-width.spec.ts` — passed.
- `pnpm exec vitest run components/task/task-item-trailing.test.tsx components/task/task-item-compact-layout.test.tsx` — passed, 2 files and 8 tests.
- `pnpm e2e:run --host --no-build tests/task/sidebar-title-width.spec.ts` — passed, 2 tests, including hover stability, Japanese phone containment, and coarse-pointer geometry.
- `pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "task row settings|title width"` — passed, 1 test.
- `python3 scripts/list-docs.py validate` — passed, 299 decisions and 1108 specifications.
- `python3 scripts/lint-spec-files.py --all` — passed.
- `git diff --check` — passed.
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-08-23
updated: 2026-08-28
updated: 2026-09-22
owners:
- kandev
---
@@ -37,7 +37,9 @@ defaults, persistence ownership, accessibility, or responsive interaction behavi
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.11:** A failed draft, create, or save request cannot roll back a later confirmed sidebar write; all sidebar writes share one synchronization journal and restore only the latest confirmed state.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.12:** The editor and task rows cause no document-level horizontal overflow at supported widths, and the mobile task row remains the primary tap target.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.13:** Relative time on the right uses localized compact seconds, minutes, hours, days, weeks, or years without direction words or calendar phrases such as **ago** or **yesterday**.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.14:** Every rendered right-side time uses the same fixed visual width, right alignment, and tabular numbers. A missing or invalid timestamp reserves no time width, and assistive technology receives the full localized relative time.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.14:** Right-side time uses right alignment and tabular numbers. A missing or invalid timestamp reserves no time width, and assistive technology receives the full localized relative time. Column sizing follows criterion 1.15.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.15:** On desktop fine-pointer rows, the time/action column occupies only the wider of the localized time and the menu button. Long titles use the remaining space after visible badges and normal row spacing. Hover, keyboard focus, and opening or closing the menu do not change the title's available width for an unchanged timestamp. Short titles keep their badges adjacent.
- **AC-UI-SIDEBAR-TASK-ROW-PRESENTATION-001.16:** Phone rows retain a visible action with a target of at least 44 by 44 CSS pixels. Time and action remain contained without overlap, and tapping the row still navigates to the task.
## Migrated source detail
@@ -95,8 +97,9 @@ belongs to the same view because a review view and a planning view can need diff
pull request status and does not cover its pointer or keyboard target.
- Relative time on the right uses a compact elapsed-time ladder: seconds, minutes, hours, days,
weeks, and years. It does not show direction words or calendar phrases.
- Every right-side time uses one fixed visual column with right-aligned tabular numbers. The full
localized relative phrase remains available to assistive technology.
- Desktop time columns fit their content with a menu-button minimum and right-aligned tabular numbers.
Their width stays stable during hover, focus, and menu use for an unchanged timestamp.
Different time values can use different widths. The full localized phrase remains available to assistive technology.
## Editor behavior
@@ -181,7 +184,7 @@ portable user settings.
request, **THEN** the provider status uses the idle far-right edge without a blank menu gap. The
menu appears beside it on row hover or keyboard focus.
- **GIVEN** relative time is selected for the right side, **WHEN** rows contain timestamps from
seconds through years ago, **THEN** each row uses the same short time column and shows only a
seconds through years ago, **THEN** each row uses a content-sized desktop time column and shows only a
localized number and unit.
- **GIVEN** a view is grouped by repository, **WHEN** repository is enabled in its details, **THEN**
the task rows omit the repeated repository while the saved field remains enabled.
@@ -206,3 +209,5 @@ portable user settings.
[Sidebar task row presentation plan](../../../plans/sidebar-task-row-presentation/plan.md)
[Compact sidebar trailing content plan](../../../plans/sidebar-task-row-compact-trailing/plan.md)
[Sidebar title width plan](../../../plans/sidebar-title-width/plan.md) supersedes the earlier fixed-width time-column delivery contract.
@@ -41,9 +41,16 @@ status. The status stays mounted and keeps its pointer and keyboard disclosure b
The branch omits the complete cluster when no status exists. The established menu-only fallback
continues to expose task actions.
The relative-time branch keeps its existing overlay swap. Its visual time column has a fixed width,
right-aligned text, and tabular numbers. Hover or menu-open state replaces the time with the menu in
that same column on fine pointers.
The relative-time branch keeps its overlay swap on desktop fine pointers. Replace both `w-11`
constraints in `TaskItemTrailing` with intrinsic time sizing and a 24 CSS pixel minimum matching
`TaskMenuButton`. Keep the time mounted and width-bearing at opacity zero during hover, focus,
and menu-open state. The absolute menu aligns to the right of the same column. This allocates
the maximum of the time width and button width without JavaScript measurement.
Keep right alignment, tabular numbers, and non-wrapping localized tokens. Different timestamps
may produce different widths; hover alone cannot. Do not restore `w-full` or add `flex-1` to
`TaskItemTitle`: long titles already shrink into the available content width, while short titles
must keep their badges adjacent. Preserve the normal outer `gap-2` spacing.
## Compact elapsed time
@@ -84,6 +91,13 @@ touch users do not depend on hover. The change-request status remains passive on
The sidebar task list remains the only scroll owner. The change does not add an overlay, safe-area
boundary, or horizontal scroll region.
Below the existing 640px action breakpoint, retain the in-flow menu slot and visible 44px action.
Phone and coarse-pointer time slots remain at least 44px wide and may grow for longer localized
tokens, keeping the non-wrapping time separate from the action. At wider coarse-pointer widths, keep
the time right-aligned within its slot and do not extend compact desktop button sizing. Scope the
content-sized desktop slot to fine pointers. Test 639px, 640px, and 768px with pointer modes
explicitly set.
## Accessibility
The change-request status keeps its current focusable tooltip trigger. Keyboard focus can reach the
@@ -101,11 +115,14 @@ time column. Neither case changes the saved view or reports a user-facing error.
Unit tests cover every time boundary, future clock skew, invalid input, the 99-year bound, and all
shipped locales. Component tests cover idle width, hover/focus classes, status reachability, menu
fallback, and the fixed time column.
fallback, and accessible time semantics. Browser geometry establishes content sizing and hover stability.
Desktop Playwright coverage proves the idle status reaches the row edge and hover reveals the menu
without covering the status. Mobile Playwright coverage proves the visible touch action, primary
row navigation, fixed time column, and absence of document horizontal overflow.
row navigation, touch-target containment, and absence of document horizontal overflow.
The [sidebar title width plan](../../../plans/sidebar-title-width/plan.md) owns the pending
replacement of fixed desktop columns. Earlier completed plans remain historical delivery records.
## Related designs