fix(ui): add mobile blocker actions (#11282)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use task properties to inspect and change task
relationships.
> - A blocked-by chip linked directly to the blocking task.
> - Its remove control appeared only on hover, so touch users could not
reach it.
> - This pull request opens a small action menu when a user taps the
chip on mobile.
> - The menu lets the user visit the task or start the existing
blocker-removal confirmation.
> - The benefit is that touch users can manage blockers without changing
the fast desktop flow.

## Linked Issues or Issue Description

**What happened?**

On a phone-width layout, a tap on a blocked-by chip opened the blocking
task immediately. The remove control appeared only on hover, so a touch
user could not remove the blocker.

**Expected behavior**

A tap on a blocked-by chip on mobile opens a menu. The menu offers
`Visit task` and `Remove blocker` actions.

**Steps to reproduce**

1. Open a task that has a blocker.
2. Use a viewport below the mobile breakpoint.
3. Open the task properties.
4. Tap the blocked-by chip.

**Paperclip version or commit**

Reproduced on `e5a7fd7038` from `master`.

**Deployment mode**

Built from source with the local development workflow.

## What Changed

- Added a mobile-only action menu to blocked-by chips.
- Kept the direct task link and hover/focus remove control on desktop.
- Reused the existing removal confirmation before the relation update.
- Added focused regression coverage for the mobile visit and remove
choices.
- Added a phone-width Storybook state with the action menu open.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/IssueProperties.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm build-storybook`
- Opened the new Storybook state in Playwright Chromium with a Pixel 5
viewport. Confirmed that both actions are visible and fit in the
viewport.

## Risks

- Low risk. The behavior change is limited to the existing mobile
breakpoint.
- Desktop navigation and blocker removal keep their current behavior.
- The menu uses the shared dropdown and dialog primitives.

> 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 `gpt-5.6-sol`, xhigh reasoning. The Codex CLI managed the
context window for this run. The model used repository tools, code
execution, tests, and browser automation.

## 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-12 12:12:55 -04:00 committed by GitHub
parent e5a7fd7038
commit 1a377424db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 207 additions and 31 deletions

View File

@ -56,12 +56,20 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSidebarState = vi.hoisted(() => ({
isMobile: false,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => mockSidebarState,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
@ -156,6 +164,11 @@ vi.mock("@/components/ui/popover", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
if (!globalThis.PointerEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
@ -433,6 +446,7 @@ describe("IssueProperties", () => {
let container: HTMLDivElement;
beforeEach(() => {
mockSidebarState.isMobile = false;
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
@ -1030,6 +1044,75 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("opens visit and remove actions when a blocked-by chip is tapped on mobile", async () => {
mockSidebarState.isMobile = true;
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeNull();
expect(container.querySelector('button[aria-label="Remove PAP-2 as blocker"]')).toBeNull();
const blockerActions = container.querySelector('button[aria-label="Actions for blocker PAP-2"]');
expect(blockerActions).not.toBeNull();
await act(async () => {
blockerActions!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
blockerActions!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const visitLink = Array.from(document.body.querySelectorAll('a[href="/issues/PAP-2"]'))
.find((link) => link.textContent?.includes("Visit task"));
expect(visitLink).not.toBeUndefined();
const removeMenuItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((item) => item.textContent?.includes("Remove blocker"));
expect(removeMenuItem).not.toBeUndefined();
await act(async () => {
removeMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
act(() => root.unmount());
});
it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,

View File

@ -18,6 +18,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
import { queryKeys } from "../../lib/queryKeys";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members";
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides";
@ -166,6 +167,7 @@ export function IssueProperties({
checkingMonitorNow = false,
}: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const { isMobile } = useSidebar();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
const { data: experimentalSettings } = useQuery({
@ -2160,7 +2162,12 @@ export function IssueProperties({
<div>
<PropertyRow label="Blocked by" wrap>
{visibleBlockedByRelations.map((relation) => (
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
<RemovableIssueReferencePill
key={relation.id}
issue={relation}
onRemove={removeBlockedBy}
isMobile={isMobile}
/>
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockedByCount}
@ -2178,7 +2185,12 @@ export function IssueProperties({
) : (
<PropertyRow label="Blocked by" wrap>
{visibleBlockedByRelations.map((relation) => (
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
<RemovableIssueReferencePill
key={relation.id}
issue={relation}
onRemove={removeBlockedBy}
isMobile={isMobile}
/>
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockedByCount}

View File

@ -2,6 +2,12 @@ import { useState, type MouseEvent } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogClose,
@ -11,16 +17,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { X } from "lucide-react";
import { ArrowUpRight, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { StatusIcon } from "../StatusIcon";
export function RemovableIssueReferencePill({
issue,
onRemove,
isMobile = false,
}: {
issue: NonNullable<Issue["blockedBy"]>[number];
onRemove: (issueId: string) => void;
isMobile?: boolean;
}) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const issueLabel = issue.identifier ?? issue.title;
@ -37,10 +45,11 @@ export function RemovableIssueReferencePill({
</>
);
const removeLabel = `Remove ${issueLabel} as blocker`;
const openRemoveConfirmation = () => setIsConfirmOpen(true);
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsConfirmOpen(true);
openRemoveConfirmation();
};
const confirmRemove = () => {
onRemove(issue.id);
@ -50,34 +59,66 @@ export function RemovableIssueReferencePill({
return (
<>
<span className="group relative inline-flex">
<button
type="button"
className="absolute -right-1 -top-1 z-10 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-(length:--rad-2) focus-visible:ring-ring group-hover:opacity-100"
aria-label={removeLabel}
title={removeLabel}
onClick={handleRemove}
>
<X className="h-3 w-3" />
</button>
{issue.identifier ? (
<Link
to={`/issues/${issueLabel}`}
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
{isMobile ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Actions for blocker ${issueLabel}`}
>
{content}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{issue.identifier ? (
<DropdownMenuItem asChild>
<Link to={`/issues/${issue.identifier}`}>
<ArrowUpRight className="h-4 w-4" />
Visit task
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem variant="destructive" onSelect={openRemoveConfirmation}>
<X className="h-4 w-4" />
Remove blocker
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task: ${issue.title}`}
>
{content}
</span>
<>
<button
type="button"
className="absolute -right-1 -top-1 z-10 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-(length:--rad-2) focus-visible:ring-ring group-hover:opacity-100"
aria-label={removeLabel}
title={removeLabel}
onClick={handleRemove}
>
<X className="h-3 w-3" />
</button>
{issue.identifier ? (
<Link
to={`/issues/${issue.identifier}`}
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
) : (
<span
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task: ${issue.title}`}
>
{content}
</span>
)}
</>
)}
</span>
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>

View File

@ -345,6 +345,40 @@ function IssuePropertiesModelOverridePane() {
);
}
function IssuePropertiesMobileBlockerActionsPane() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const openTimer = window.setTimeout(() => {
const trigger = rootRef.current?.querySelector<HTMLButtonElement>(
'button[aria-label^="Actions for blocker"]',
);
if (!trigger) return;
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger.click();
}, 0);
return () => window.clearTimeout(openTimer);
}, []);
return (
<StorybookData>
<div ref={rootRef} className="paperclip-story min-h-screen p-4">
<div className="mx-auto max-w-sm border border-border bg-background">
<div className="border-b border-border px-4 py-2 text-sm font-medium">Properties</div>
<div className="p-4">
<IssueProperties
issue={storybookIssues[1]!}
childIssues={[]}
onUpdate={() => undefined}
inline
/>
</div>
</div>
</div>
</StorybookData>
);
}
function ColumnConfigurationMatrix() {
const [columns, setColumns] = useState<InboxIssueColumn[]>(visibleColumns);
const visibleColumnSet = useMemo(() => new Set(columns), [columns]);
@ -927,6 +961,12 @@ export const IssuePropertiesModelOverride: Story = {
render: () => <IssuePropertiesModelOverridePane />,
};
export const IssuePropertiesMobileBlockerActions: Story = {
name: "IssueProperties - mobile blocker actions open",
render: () => <IssuePropertiesMobileBlockerActionsPane />,
parameters: { viewport: { defaultViewport: "mobile1" } },
};
function ModelProfileLedgerStandalone() {
return (
<StorybookData>