From f8720dca6a3b09930a159378933595c0abdd05ca Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 28 Jun 2026 14:35:16 -0400 Subject: [PATCH 1/2] fix(ui): add reliable navbar unpin for pinned issues --- DEVLOG.md | 23 +++++++++ src/app/(app)/w/[slug]/issues/[id]/page.tsx | 10 ++-- src/components/pins/pin-button.tsx | 6 ++- src/components/pins/pins-strip.tsx | 56 +++++++++++++++++++-- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index cfae7c59..955d38b4 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,29 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-06-28 — Navbar pinned issue unpin affordance + +Closed AXI-89. Root cause: the cross-workspace topbar pin strip rendered +pinned items as pure links, so pinned issues had no direct removal affordance +there; the issue detail `p` shortcut had also drifted to the workspace/sidebar +pin bucket even though the UI comment said it controlled the navbar strip. + +Changes: +- `PinsStrip` now gives every pinned navbar chip a visible `×` button with + `Unpin from navbar: …` title/aria-label. It calls `pin.remove({ id })`, + invalidates both modern/legacy pin caches, broadcasts `pins:updated`, and + shows an `Unpinned` toast. +- `PinButton` labels now distinguish `navbar` vs `sidebar` pins and include + the shortcut hint when present. +- Issue detail now uses the cross-workspace/navbar issue pin (`workspaceId: + null`) for the visible pin control and `p` shortcut, so a pinned issue can be + removed from either the navbar chip or its own issue controls. + +Verification: `pnpm typecheck`; `pnpm lint` (passes with existing native-select +warnings); `set -a; source /home/bailey/forge/.env; set +a; unset +OPENAI_API_KEY; pnpm test` → 1027 passed / 1 skipped; same env `pnpm +build:app` → Next build successful. + ## 2026-06-27 — Subject-label resolver (audit log + webhook deliveries) Closed the deferral from the gap sweep: raw `subjectId.slice(0,8)` on the diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index ffff9332..14195183 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -402,15 +402,13 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const reason = coerceDispatchReason(issue.dispatchReason); return reason ? : null; })()} - {/* Phase 1A: per-workspace pin toggle. Lives next to the - chip cluster so it visually groups with the issue's other - metadata. The legacy `` higher up in the - Topbar actions still drives the cross-workspace strip - (issue-only, p shortcut). */} + {/* Cross-workspace/navbar pin toggle. It mirrors the topbar + pinned strip, so a pinned issue can always be unpinned from + either the navbar chip or the issue's own controls. */} diff --git a/src/components/pins/pin-button.tsx b/src/components/pins/pin-button.tsx index 164e2a9c..190e423c 100644 --- a/src/components/pins/pin-button.tsx +++ b/src/components/pins/pin-button.tsx @@ -118,7 +118,11 @@ export function PinButton({ const dim = size === "md" ? "h-7 w-7" : "h-6 w-6"; const iconDim = size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"; - const label = pinned ? "Unpin from sidebar" : "Pin to sidebar"; + const pinSurface = workspaceId === null ? "navbar" : "sidebar"; + const shortcutHint = shortcut ? ` (${shortcut})` : ""; + const label = pinned + ? `Unpin from ${pinSurface}${shortcutHint}` + : `Pin to ${pinSurface}${shortcutHint}`; return ( + + ); +} + +function PinChipLink({ target }: { target: HydratedPinTarget }) { switch (target.targetType) { case "ISSUE": return ; @@ -270,7 +320,7 @@ function PinChip({ target }: { target: HydratedPinTarget }) { } const STRIP_BASE = cn( - "focus-ring group inline-flex h-7 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-card/60 pl-1 pr-2 text-left hover:bg-subtle", + "focus-ring group inline-flex h-7 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-card/60 pl-1 pr-7 text-left hover:bg-subtle", MOTION.base, ); From 4313cb0b83974e34f2af35063aa3810bb8d3e04a Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 28 Jun 2026 15:14:43 -0400 Subject: [PATCH 2/2] test(e2e): cover navbar pin removal --- DEVLOG.md | 11 ++++++++- src/components/ui/anchored-popover.tsx | 3 +++ src/components/ui/combobox.tsx | 6 ++++- src/components/ui/date-picker.tsx | 2 ++ tests/e2e/issue-flow.spec.ts | 32 ++++++++++++++++++++++---- tests/e2e/mobile-smoke.spec.ts | 12 +++++++--- tests/e2e/sprints-roadmap.spec.ts | 28 ++++++++++++++++++---- 7 files changed, 81 insertions(+), 13 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 955d38b4..3b494123 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -19,11 +19,20 @@ Changes: - Issue detail now uses the cross-workspace/navbar issue pin (`workspaceId: null`) for the visible pin control and `p` shortcut, so a pinned issue can be removed from either the navbar chip or its own issue controls. +- Shared `Combobox` now advertises the ARIA combobox role/controls so the + issue status/priority controls are discoverable by role-based tests and AT. +- `DatePicker` popovers align right and flip earlier so roadmap date pickers + stay inside the viewport when opened near the right/bottom edge. +- E2E coverage now exercises create → pin issue to navbar → unpin directly + from the navbar chip → transition status, plus updates stale mobile/roadmap + tests to the current combobox/date-picker UI. Verification: `pnpm typecheck`; `pnpm lint` (passes with existing native-select warnings); `set -a; source /home/bailey/forge/.env; set +a; unset OPENAI_API_KEY; pnpm test` → 1027 passed / 1 skipped; same env `pnpm -build:app` → Next build successful. +build:app` → Next build successful; same env `E2E_FORCE_BUILD=0 pnpm exec +playwright test tests/e2e/issue-flow.spec.ts tests/e2e/mobile-smoke.spec.ts +tests/e2e/sprints-roadmap.spec.ts --workers=1` → 10 passed. ## 2026-06-27 — Subject-label resolver (audit log + webhook deliveries) diff --git a/src/components/ui/anchored-popover.tsx b/src/components/ui/anchored-popover.tsx index 77498dc4..a877c69e 100644 --- a/src/components/ui/anchored-popover.tsx +++ b/src/components/ui/anchored-popover.tsx @@ -28,6 +28,7 @@ import { cn } from "@/lib/utils"; * the pointer can cross the gap into the portaled panel without it closing. */ export function AnchoredPopover({ + id, anchorRef, open, onClose, @@ -44,6 +45,7 @@ export function AnchoredPopover({ onMouseLeave, children, }: { + id?: string; /** The trigger element (or its wrapper) the popover anchors to. */ anchorRef: RefObject; open: boolean; @@ -139,6 +141,7 @@ export function AnchoredPopover({ return createPortal(
(null); const inputRef = useRef(null); + const listboxId = useId(); const isAsync = !!onQueryChange; const showSearch = searchable || isAsync; @@ -134,8 +135,10 @@ export function Combobox({ setOpen(false)} diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index 21a529d7..e43f2add 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -177,6 +177,8 @@ export function DatePicker({ onClose={() => setOpen(false)} role="dialog" ariaLabel="Date picker" + align="right" + minSpaceBelow={280} className="w-64 rounded-lg border border-border bg-card p-3 shadow-xl" >
diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index e0a90b01..29004d6c 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; /** * Golden-path E2E: create an issue via the quick-create dialog, verify it @@ -6,8 +6,20 @@ import { test, expect } from "@playwright/test"; * storageState minted in global-setup against the seeded `forge` workspace. */ +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function selectComboboxOption(page: Page, name: string, option: string) { + const combo = page.getByRole("combobox", { name }); + await expect(combo).toBeVisible(); + await combo.click(); + await page.getByRole("option", { name: option, exact: true }).click(); + await expect(combo).toContainText(option); +} + test.describe("Issue flow", () => { - test("create and transition an issue", async ({ page }) => { + test("create, pin/unpin from navbar, and transition an issue", async ({ page }) => { await page.goto("/w/forge/inbox"); // QuickCreate opens on the ⇧C global hotkey (src/components/quick-create.tsx). @@ -28,8 +40,20 @@ test.describe("Issue flow", () => { await expect(page).toHaveURL(/\/w\/forge\/issues\//, { timeout: 20_000 }); await expect(page.getByText(title).first()).toBeVisible(); + // The issue-detail pin control targets the navbar pin bucket. Once pinned, + // the navbar chip exposes its own direct unpin button so users do not have + // to hunt through other issue controls to remove the pin. + await page.getByRole("button", { name: "Pin to navbar (p)" }).click(); + await expect(page.getByRole("button", { name: "Unpin from navbar (p)" })).toBeVisible(); + const navbarUnpin = page.getByRole("button", { + name: new RegExp(`^Unpin from navbar: .*${escapeRegExp(title)}`), + }); + await expect(navbarUnpin).toBeVisible(); + await navbarUnpin.click(); + await expect(page.getByRole("button", { name: "Pin to navbar (p)" })).toBeVisible(); + await expect(navbarUnpin).toHaveCount(0); + // Move status on the detail page and confirm it sticks. - await page.locator("select").first().selectOption({ label: "In Progress" }); - await expect(page.locator("select").first()).toHaveValue(/.+/); + await selectComboboxOption(page, "Status", "In Progress"); }); }); diff --git a/tests/e2e/mobile-smoke.spec.ts b/tests/e2e/mobile-smoke.spec.ts index 36312342..3014f843 100644 --- a/tests/e2e/mobile-smoke.spec.ts +++ b/tests/e2e/mobile-smoke.spec.ts @@ -43,6 +43,14 @@ async function createIssueFromMobileTopbar(page: Page, title: string) { await expect(page.getByText(title).first()).toBeVisible(); } +async function selectComboboxOption(page: Page, name: string, option: string) { + const combo = page.getByRole("combobox", { name }); + await expect(combo).toBeVisible(); + await combo.click(); + await page.getByRole("option", { name: option, exact: true }).click(); + await expect(combo).toContainText(option); +} + test.describe("Mobile smoke", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { @@ -199,9 +207,7 @@ test.describe("Mobile smoke", () => { await page.getByRole("button", { name: /^Comment/ }).click(); await expect(page.locator("span", { hasText: comment }).first()).toBeVisible(); - const status = page.getByRole("combobox", { name: "Status" }); - await status.selectOption({ label: "In Progress" }); - await expect(status).toHaveValue(/.+/); + await selectComboboxOption(page, "Status", "In Progress"); await page.getByTitle(/Assign agent/i).click(); await expect(page.getByRole("dialog")).toBeVisible(); diff --git a/tests/e2e/sprints-roadmap.spec.ts b/tests/e2e/sprints-roadmap.spec.ts index 34694ccd..c6712b11 100644 --- a/tests/e2e/sprints-roadmap.spec.ts +++ b/tests/e2e/sprints-roadmap.spec.ts @@ -1,4 +1,22 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +function monthDelta(from: Date, to: Date) { + return (to.getFullYear() - from.getFullYear()) * 12 + to.getMonth() - from.getMonth(); +} + +async function pickDate(page: Page, label: string, target: Date) { + await page.getByRole("button", { name: label, exact: true }).click(); + const picker = page.getByRole("dialog", { name: "Date picker" }); + await expect(picker).toBeVisible(); + + const delta = monthDelta(new Date(), target); + const direction = delta >= 0 ? "Next month" : "Previous month"; + for (let i = 0; i < Math.abs(delta); i += 1) { + await picker.getByRole("button", { name: direction }).click(); + } + + await picker.getByRole("button", { name: String(target.getDate()), exact: true }).click(); +} test.describe("Sprints and roadmap management", () => { test("sprints exposes management, rollover, and collapsible backlog", async ({ page }) => { @@ -45,9 +63,11 @@ test.describe("Sprints and roadmap management", () => { await dateButton.click(); await expect(page.getByRole("dialog", { name: "Project roadmap dates" })).toBeVisible(); - const dates = page.locator('input[type="date"]'); - await dates.nth(0).fill("2026-08-03"); - await dates.nth(1).fill("2026-08-14"); + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth() + 1, 3); + const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 14); + await pickDate(page, "Start date", startDate); + await pickDate(page, "Target date", targetDate); await page.getByRole("button", { name: "Save dates" }).click(); await expect(page.getByText("Roadmap dates updated.")).toBeVisible(); });