Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

> 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.
- 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; 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)

Closed the deferral from the gap sweep: raw `subjectId.slice(0,8)` on the
Expand Down
10 changes: 4 additions & 6 deletions src/app/(app)/w/[slug]/issues/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,13 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
const reason = coerceDispatchReason(issue.dispatchReason);
return reason ? <DispatchReasonChip reason={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 `<PinToggleButton>` 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. */}
<PinButton
targetType="ISSUE"
targetId={issue.id}
workspaceId={workspace.id}
workspaceId={null}
shortcut="p"
/>
<WatchButton issueId={issue.id} />
Expand Down
6 changes: 5 additions & 1 deletion src/components/pins/pin-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
Expand Down
56 changes: 53 additions & 3 deletions src/components/pins/pins-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
Pin as PinIcon,
X,
FolderKanban,
Diamond,
Filter,
Expand Down Expand Up @@ -41,6 +42,16 @@ export function PinsStrip() {
const { data } = trpc.pin.listAll.useQuery({ workspaceId: null });
const pins = (data ?? []) as HydratedPin[];

const removeMut = trpc.pin.remove.useMutation({
onSuccess: () => {
void utils.pin.listAll.invalidate();
void utils.pin.list.invalidate();
broadcastCrossTab({ type: "pins:updated" });
toast.success("Unpinned");
},
onError: (e) => toast.error(e.message),
});

// If another tab toggles a pin, refresh our cached list so the strip
// stays consistent across windows. Pins are user-scoped (not tied to
// a single workspace) so the BroadcastChannel is the right bus here;
Expand All @@ -62,7 +73,14 @@ export function PinsStrip() {
if (!pin || !pin.target) {
return <EmptyPinSlot key={`empty-${idx}`} alreadyPinnedIds={pinnedKeySet(pins)} />;
}
return <PinChip key={pin.id} target={pin.target} />;
return (
<PinChip
key={pin.id}
pin={pin}
onRemove={() => removeMut.mutate({ id: pin.id })}
removing={removeMut.isPending}
/>
);
})}
</div>
);
Expand Down Expand Up @@ -252,7 +270,39 @@ function TargetIcon({ kind }: { kind: HydratedPinTarget["targetType"] }) {
* keeps the same outer dimensions (`h-7`) so the strip stays a uniform
* height regardless of which mix of pin types the user has.
*/
function PinChip({ target }: { target: HydratedPinTarget }) {
function PinChip({
pin,
onRemove,
removing,
}: {
pin: HydratedPin;
onRemove: () => void;
removing: boolean;
}) {
if (!pin.target) return null;
const label = labelFor(pin.target);
return (
<div className="group/pinned-strip relative inline-flex min-w-0">
<PinChipLink target={pin.target} />
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
disabled={removing}
title={`Unpin from navbar: ${label}`}
aria-label={`Unpin from navbar: ${label}`}
className="focus-ring absolute right-1 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-card hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-3 w-3" />
</button>
</div>
);
}

function PinChipLink({ target }: { target: HydratedPinTarget }) {
switch (target.targetType) {
case "ISSUE":
return <IssueChip target={target} />;
Expand All @@ -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,
);

Expand Down
3 changes: 3 additions & 0 deletions src/components/ui/anchored-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,6 +45,7 @@ export function AnchoredPopover({
onMouseLeave,
children,
}: {
id?: string;
/** The trigger element (or its wrapper) the popover anchors to. */
anchorRef: RefObject<HTMLElement | null>;
open: boolean;
Expand Down Expand Up @@ -139,6 +141,7 @@ export function AnchoredPopover({

return createPortal(
<div
id={id}
ref={popoverRef}
role={role}
aria-label={ariaLabel}
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnchoredPopover } from "@/components/ui/anchored-popover";
Expand Down Expand Up @@ -79,6 +79,7 @@ export function Combobox({
const [active, setActive] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listboxId = useId();

const isAsync = !!onQueryChange;
const showSearch = searchable || isAsync;
Expand Down Expand Up @@ -134,8 +135,10 @@ export function Combobox({
<button
ref={triggerRef}
type="button"
role="combobox"
disabled={disabled}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((v) => !v)}
Expand All @@ -161,6 +164,7 @@ export function Combobox({
</button>

<AnchoredPopover
id={listboxId}
anchorRef={triggerRef}
open={open}
onClose={() => setOpen(false)}
Expand Down
2 changes: 2 additions & 0 deletions src/components/ui/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="mb-2 flex items-center justify-between">
Expand Down
32 changes: 28 additions & 4 deletions tests/e2e/issue-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
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
* appears in the inbox list, open it, move status. Runs authenticated via the
* 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).
Expand All @@ -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");
});
});
12 changes: 9 additions & 3 deletions tests/e2e/mobile-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 24 additions & 4 deletions tests/e2e/sprints-roadmap.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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();
});
Expand Down
Loading