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:
parent
e5a7fd7038
commit
1a377424db
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue