fix(ui): keep mobile unread dots from indenting task rows (#10691)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-03 10:20:04 -05:00 committed by GitHub
parent 2c90cf0f2c
commit d5045d622c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 13 additions and 8 deletions

View File

@ -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(<IssueRow issue={createIssue()} unreadState="visible" />);
@ -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();

View File

@ -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({
</span>
) : 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.
<span className="order-first inline-flex h-4 w-4 shrink-0 items-center justify-center self-center sm:hidden">
// 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.
<span className="absolute left-0 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center sm:hidden">
{unreadDotButton}
</span>
) : null}