From d5045d622c737fff91bc9638b7595dcd9bfe86c0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:20:04 -0500 Subject: [PATCH] fix(ui): keep mobile unread dots from indenting task rows (#10691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Inbox helps operators scan task state on desktop and mobile screens > - Read and unread rows must keep the same title and status alignment > - The mobile unread control was a flex item, so unread rows gained an extra leading column > - Moving the control out of flex flow fixes the indent, but the tap target must stay inside clipped row containers > - This pull request reserves one mobile gutter for both states and overlays the unread control inside that gutter > - The benefit is stable mobile alignment without clipping the control or blocking the parent-row chevron ## Linked Issues or Issue Description Related merged work: Refs #9383, Refs #9685, and Refs #9767. **What happened?** On mobile screens, an unread Inbox row placed the mark-as-read control in the flex layout. The extra flex item moved the status and title to the right. A first overlay position could also place part of the control outside a row that clips overflow. **Expected behavior** Read and unread rows must use the same title and status positions. The unread control must stay tappable inside the row. A parent-row chevron must remain independently usable. **Steps to reproduce** 1. Open the Inbox on a mobile viewport. 2. Compare read and unread rows with the same nesting depth. 3. Include an unread parent row with a collapse chevron. 4. Observe that the unread row content starts farther right than the read row content. **Paperclip version or commit** The issue reproduced on the `master` parent of this pull request. **Deployment mode** Local dev (`pnpm dev`). **Installation method** Built from source. ## What Changed - Reserve a mobile leading gutter whenever an Inbox row participates in unread state. - Position the mark-as-read control absolutely inside that gutter so it does not add a flex column. - Keep read and unread rows on the same mobile padding path. - Keep the control inside overflow-clipping row containers and separate from the parent-row chevron. - Update the `IssueRow` regression test to verify absolute placement, the internal gutter, and the absence of `order-first` layout. ## Verification - `pnpm exec vitest run ui/src/components/IssueRow.test.tsx` — 16 tests passed. - `pnpm check:token-gates` — all token gates passed across 722 files. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — the general server shard passed 3,448 tests and the UI shard passed 3,362 tests. The CLI shard had one host-environment failure because inherited temporary AWS access-key variables changed an AWS doctor assertion from `pass` to `warn`. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN pnpm exec vitest run cli/src/__tests__/secrets.test.ts` — the affected file passed 8 tests in an isolated environment. - Mobile QA at 402 × 874 confirmed equal read/unread title positions, no shift after marking a row as read, and an independently usable parent chevron. ## Risks - Low risk. The change affects only `IssueRow` mobile presentation and its focused regression test. - The main risk is breakpoint-specific placement. The component test covers the responsive classes, and mobile QA covers the rendered interaction. - No API, schema, dependency, telemetry, or documentation contract changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5, with reasoning, repository tools, command execution, and test execution. The runtime did not expose the exact deployment ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/IssueRow.test.tsx | 10 +++++++--- ui/src/components/IssueRow.tsx | 11 ++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index d087885a2e..5f85bba4f8 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -218,7 +218,7 @@ describe("IssueRow", () => { }); }); - it("puts the unread dot in the reserved far-left slot on desktop and in flow on mobile", () => { + it("puts the unread dot in the reserved desktop slot and overlays it on mobile", () => { const root = createRoot(container); act(() => { root.render(); @@ -230,12 +230,16 @@ describe("IssueRow", () => { expect(slot).not.toBeNull(); expect(slot?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull(); - // Mobile: a separate in-flow, order-first dot (mobile has no reserved slot). + // Mobile: all inbox rows reserve a gutter and the dot stays fully inside + // it, so the control cannot be clipped or indent the status/title. const mobileDot = container .querySelector('button[aria-label="Mark as read"].sm\\:hidden, span.sm\\:hidden button[aria-label="Mark as read"]') ?.closest("span.sm\\:hidden"); expect(mobileDot).not.toBeNull(); - expect(mobileDot?.className).toContain("order-first"); + expect(mobileDot?.className).toContain("absolute"); + expect(mobileDot?.className).toContain("left-0"); + expect(mobileDot?.className).not.toContain("order-first"); + expect(container.firstElementChild?.className).toContain("pl-4"); act(() => { root.unmount(); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index a175417c43..bcede8f1dd 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -168,7 +168,8 @@ export function IssueRow({ // No color transition on the row band: hover/selection must snap // instantly. A fade (transition-colors) leaves a trail of fading bands // when scrubbing the mouse fast across the list. - "group relative flex items-start gap-2 rounded-lg py-2.5 pl-2 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1", + "group relative flex items-start gap-2 rounded-lg py-2.5 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1", + showUnreadSlot ? "pl-4" : "pl-2", "[&_button]:relative [&_button]:z-10", // Divider + hover/selected/checklist wash live on the ROOT row band so // the tint paints BEHIND the content and `last:border-b-0` matches the @@ -323,10 +324,10 @@ export function IssueRow({ ) : null} {showUnreadDot ? ( - // Mobile keeps the dot in flow as the leading item (mobile has no - // reserved desktop dot gutter). Desktop renders the dot in the reserved - // leading slot above instead, so this is mobile-only. - + // Inbox rows reserve a mobile gutter on both read and unread rows. The + // full control stays inside overflow-clipping row containers while its + // absolute position avoids shifting or covering the leading control. + {unreadDotButton} ) : null}