[codex] Polish issue interaction selectors (#7668)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents for work.
> - Issue-thread interactions are the board/agent handoff surface for
structured decisions and confirmations.
> - Accepted checkbox confirmations can include many selected labels,
and inline selectors must stay usable across desktop and mobile devices.
> - The existing accepted checkbox summary capped labels behind a static
`+N more` chip, so operators could not inspect the hidden selections
from the card.
> - The inline selector also skipped search focus on coarse pointers,
which made mobile selection slower and less predictable.
> - This pull request makes the hidden checkbox selections expandable
and restores search focus when the selector opens.
> - The benefit is a more inspectable, faster interaction UI without
changing the interaction API contract.

## Linked Issues or Issue Description

Internal source work: [PAP-10488](/PAP/issues/PAP-10488), split from
[PAP-10495](/PAP/issues/PAP-10495).

### Subsystem affected

ui/ - React + Vite board UI

### Problem or motivation

Accepted checkbox confirmations hide selected values behind a static
count, and inline selector search does not focus on mobile/coarse
pointer devices. That makes accepted interaction cards less inspectable
and makes mobile selection slower than it needs to be.

### Proposed solution

Make hidden accepted checkbox selections expandable/collapsible directly
in the interaction card, and focus the inline selector search input
whenever the selector opens, including coarse pointer environments.

### Alternatives considered

Leaving the static `+N more` chip avoids UI state, but it keeps selected
values hidden from operators. Only restoring desktop focus keeps the old
mobile/coarse pointer gap, so the focus behavior should be consistent
across pointer types.

### Roadmap alignment

This is a small board UI polish change for the existing issue
interaction surface. It does not add a new core roadmap capability or
change the API contract.

## What Changed

- Converted the accepted checkbox `+N more` chip into an expandable
button with a `Show less` control.
- Restored search input focus whenever `InlineEntitySelector` opens,
including coarse pointer environments.
- Updated focused component tests for the expanded selection summary and
mobile/coarse-pointer focus path.
- Adjusted the interaction card test harness to use the same `flushSync`
act helper style used by nearby focused component tests.

## Verification

- `pnpm exec vitest run
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/InlineEntitySelector.test.tsx` passed in
`~paperclipai/paperclip/.paperclip/worktrees/PAP-10495-interaction-selector-polish`.

## Risks

- Low risk. The change is limited to UI state and focus behavior in
existing components.
- Restoring focus on coarse pointers may open a virtual keyboard on
mobile, but that is intentional for the faster search workflow requested
by this branch.

> 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 coding agent based on GPT-5, with local repository
inspection, shell execution, git, and GitHub CLI tool use. Runtime
context window was not exposed by the environment.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] 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-06-06 16:19:31 -05:00
committed by GitHub
parent 86253f52a7
commit bb880d9948
4 changed files with 127 additions and 17 deletions
@@ -1,22 +1,33 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InlineEntitySelector } from "./InlineEntitySelector";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
describe("InlineEntitySelector", () => {
let container: HTMLDivElement;
let originalMatchMedia: typeof window.matchMedia;
beforeEach(() => {
originalMatchMedia = window.matchMedia;
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
container.remove();
document.body.innerHTML = "";
});
@@ -77,4 +88,51 @@ describe("InlineEntitySelector", () => {
root.unmount();
});
});
it("focuses the search input when opened on coarse pointers", async () => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === "(pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const root = createRoot(container);
act(() => {
root.render(
<InlineEntitySelector
value=""
options={[
{ id: "agent:agent-1", label: "CodexCoder" },
{ id: "agent:agent-2", label: "DesignBot" },
]}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={vi.fn()}
/>,
);
});
const trigger = container.querySelector("button") as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const searchInput = document.querySelector('input[placeholder="Search assignees..."]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
expect(document.activeElement).toBe(searchInput);
act(() => {
root.unmount();
});
});
});
@@ -135,15 +135,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
disablePortal={disablePortal}
onOpenAutoFocus={(event) => {
event.preventDefault();
// On touch devices, don't auto-focus the search input to avoid
// opening the virtual keyboard which reshapes the viewport and
// pushes the popover off-screen.
const isTouch = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)").matches
: false;
if (!isTouch) {
inputRef.current?.focus();
}
}}
onCloseAutoFocus={(event) => {
if (!shouldPreventCloseAutoFocusRef.current) return;
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
@@ -34,6 +34,10 @@ vi.mock("@/lib/router", () => ({
),
}));
function act(callback: () => void) {
flushSync(callback);
}
function renderCard(
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
) {
@@ -460,6 +464,48 @@ describe("IssueThreadInteractionCard", () => {
expect(host.textContent).toContain("+34 more");
});
it("expands the hidden accepted selections when the +N more chip is clicked", () => {
const host = renderCard({
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
});
const countSelectedChips = () =>
Array.from(host.querySelectorAll("*")).filter(
(node) => node.children.length === 0 && node.textContent?.trim().startsWith("Selected:"),
).length;
expect(countSelectedChips()).toBe(8);
const moreButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("+34 more"),
);
expect(moreButton).toBeTruthy();
moreButton?.focus();
expect(document.activeElement).toBe(moreButton);
act(() => {
moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).not.toContain("+34 more");
expect(countSelectedChips()).toBe(42);
expect(document.activeElement).toBe(moreButton);
const showLessButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Show less"),
);
expect(showLessButton).toBeTruthy();
expect(showLessButton).toBe(moreButton);
act(() => {
showLessButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(countSelectedChips()).toBe(8);
expect(host.textContent).toContain("+34 more");
expect(document.activeElement).toBe(moreButton);
});
it("stays compact and scrollable with around 100 options", () => {
const host = renderCard({
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
@@ -1335,6 +1335,7 @@ function RequestCheckboxConfirmationResolution({
interaction: RequestCheckboxConfirmationInteraction;
}) {
const target = interaction.payload.target ?? null;
const [expanded, setExpanded] = useState(false);
if (interaction.status === "accepted") {
const totalOptions = interaction.payload.options.length;
@@ -1343,8 +1344,13 @@ function RequestCheckboxConfirmationResolution({
result: interaction.result,
});
const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length;
const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
const hiddenCount = selectedLabels.length - visibleLabels.length;
const visibleLabels = expanded
? selectedLabels
: selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
const hiddenCount = selectedLabels.length - CHECKBOX_SUMMARY_LABEL_LIMIT;
const hasHiddenLabels = hiddenCount > 0;
const chipClassName =
"inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground";
return (
<div className="space-y-3">
@@ -1361,10 +1367,18 @@ function RequestCheckboxConfirmationResolution({
{visibleLabels.map((label, index) => (
<TaskField key={`${label}-${index}`} label="Selected" value={label} />
))}
{hiddenCount > 0 ? (
<span className="inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
+{hiddenCount} more
</span>
{hasHiddenLabels ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className={cn(
chipClassName,
"cursor-pointer transition-colors hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
aria-expanded={expanded}
>
{expanded ? "Show less" : `+${hiddenCount} more`}
</button>
) : null}
</div>
) : null}