#3330 · Missing touch visibility on project header controls

BugPriority: LowEffort: Lowui, mobileIssue · 2026-09-09

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The individual project header wraps its display control in a hover-only container. That container lacks the marker which makes adjacent project actions visible on compact touch screens. A browser fixture extracted from the production JSX and CSS reproduces an invisible control that rejects touch input in two clean checkouts. Applying the existing marker makes the same browser assertion pass and lets a touch tap reach the control.

This is a focused component/CSS reproduction, not a screenshot of the full BB app. The fixture uses simplified text and a button inside the unmodified production wrapper; it does not simulate the real display-options menu. Full-app and Safari checks are not established by this evidence.

2. Claims vs findings

ClaimFindingEvidence
Project display control is hidden on compact touch screensVerified at wrapper/CSS boundaryBoth clean browser runs: opacity 0, pointer-events none, real tap missed
The media-query condition is satisfiedVerified393 × 852, pointer coarse and max-width 767px both true
Other project actions use the mobile markerVerified in sourceAdjacent actions container already opts into mobile visibility
Built-in section menus are unaffectedSource reviewed; full menu journey unverifiedSeparate section action containers use the mobile marker
Historical release affectedUnverifiedOnly latest trusted origin/main was executed

3. Environment

Trusted repository: public get-bb/bb. Base: a86e829f42edb4ab0960f3d8b55a17785860cf75, fetched from origin/main on 2026-09-09. Darwin arm64, Node 22.22.3, pnpm 9.15.0 via a temporary Corepack shim, Vitest 4.1.1, headless Chrome controlled by doobie 0.1.2. No provider sessions, credentials, imported stores, or user data were used. Static fixture servers bound only to 127.0.0.1:48930 and :48931. The browser used a dedicated temporary-task profile.

4. Minimal reproduction

  1. Download the fixture extractor. Create an empty output directory, then run the commands below using the trusted commit.
  2. Open the generated fixture in Chrome at 393 × 852 with touch enabled.
  3. Inspect the control wrapper and tap the geometric center of the hidden button. Expected: opacity 1, pointer-events auto, and the text changes to “Tap received”. Actual: opacity 0, pointer-events none, and “No tap received” remains.
  4. For the React regression, copy the test file into the same repository path and run pnpm exec turbo run test --filter=@bb/app -- ProjectRow.interactions --testNamePattern='keeps project header controls' --maxWorkers=1 --testTimeout=60000.
git clone --branch main https://github.com/get-bb/bb.git bb-3330
cd bb-3330
git checkout a86e829f42edb4ab0960f3d8b55a17785860cf75
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node /path/to/render-fixture.cjs "$PWD" /tmp/3330-repro/base.html
python3 -m http.server 48930 --bind 127.0.0.1 --directory /tmp/3330-repro

Repeatable browser check · Two-run browser output · Generated baseline fixture

base before { opacity: '0', pointerEvents: 'none', coarse: true, compact: true, hit: '', result: 'No tap received' }
base after tap { opacity: '0', pointerEvents: 'none', coarse: true, compact: true, hit: '', result: 'No tap received' }
verify before { opacity: '0', pointerEvents: 'none', coarse: true, compact: true, hit: '', result: 'No tap received' }
verify after tap { opacity: '0', pointerEvents: 'none', coarse: true, compact: true, hit: '', result: 'No tap received' }

The visibility assertion failed before the fix with:

Error: Expected visible tappable header control; got {"opacity":"0","pointerEvents":"none"}
Browser fixture with hidden display control
Baseline compact-touch fixture: the display control occupies space to the right of QA project but is invisible.
Second checkout fixture after a missed touch tap
Second clean checkout after a real tap: the control is still invisible and the tap counter remains unchanged.
Fixed fixture with a visible display control and successful tap
After adding the existing mobile marker, the control is visible and receives the touch tap.
Fixture extractor source
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const root = path.resolve(process.argv[2]);
const req = createRequire(path.join(root, 'apps/app/package.json'));
const ts = req('typescript');
const React = req('react');
const { renderToStaticMarkup } = req('react-dom/server');
const source = fs.readFileSync(path.join(root, 'apps/app/src/components/sidebar/ProjectRow.tsx'), 'utf8');
const start = source.indexOf('{headerActions ? (', source.indexOf('const projectActions ='));
const end = source.indexOf(') : null}', start) + ') : null}'.length;
if (start < 0 || end < start) throw new Error('Header slot not found');
const constants = fs.readFileSync(path.join(root, 'apps/app/src/components/ui/sidebar-hover-actions.ts'), 'utf8');
const constant = name => {
  const match = constants.match(new RegExp('export const ' + name + '\\s*=\\s*"([^"]+)"'));
  if (!match) throw new Error('Constant not found: ' + name);
  return match[1];
};
const jsx = 'return <>' + source.slice(start, end) + '</>;';
const compiled = ts.transpileModule('function fixture(){' + jsx + '}', {compilerOptions:{jsx:ts.JsxEmit.React, target:ts.ScriptTarget.ES2022}}).outputText;
const make = new Function('React','headerActions','headerActionsOpen','SIDEBAR_HOVER_ACTIONS_CLASS','SIDEBAR_HOVER_ACTIONS_MOBILE_ALWAYS_VALUE',compiled + ';return fixture();');
const control = React.createElement('button', {'aria-label':'Display options', id:'display'}, 'Display options');
const markup = renderToStaticMarkup(make(React, control, false, constant('SIDEBAR_HOVER_ACTIONS_CLASS'), constant('SIDEBAR_HOVER_ACTIONS_MOBILE_ALWAYS_VALUE')));
const theme = fs.readFileSync(path.join(root, 'apps/app/src/components/ui/theme.css'), 'utf8');
const cssStart = theme.indexOf('  .bb-sidebar-hover-actions {');
const cssEnd = theme.indexOf('  [data-sidebar-sticky-stack]', cssStart);
if (cssStart < 0 || cssEnd < 0) throw new Error('Hover CSS not found');
const css = theme.slice(cssStart, cssEnd);
const html = '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>' + css + '\nbody{font:16px system-ui;margin:24px;background:#fafaf8;color:#1a1a1a}section{border:1px solid #ccc;padding:16px}button{font:inherit;padding:8px}.bb-sidebar-hover-actions-row{display:flex;align-items:center;justify-content:space-between;gap:8px}</style></head><body><h1>Project header probe</h1><p>Isolated production wrapper and hover CSS. Simplified label and button.</p><section class="bb-sidebar-hover-actions-row"><strong>QA project</strong>' + markup + '</section><p id="result">No tap received</p><script>document.querySelector("#display").onclick=()=>document.querySelector("#result").textContent="Tap received";</script></body></html>';
fs.writeFileSync(process.argv[3], html);
console.log(JSON.stringify({wrapper:markup,source:'ProjectRow.tsx',css:'theme.css'}));
Regression test source
// @vitest-environment jsdom

import {
  cleanup,
  fireEvent,
  render,
  screen,
  waitFor,
} from "@testing-library/react";
import type { ThreadListEntry } from "@bb/domain";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { TooltipProvider } from "@bb/shared-ui/tooltip";
import { Provider, createStore } from "jotai";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
  ChronologicalSectionThreadSections,
  ProjectRow,
  type ProjectThreadListState,
} from "./ProjectRow";
import { buildSidebarEntitySectionId } from "@bb/client-core";
import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
import { makeProjectResponse } from "@/test/fixtures/projects";

const mockUpdateEnvironment = vi.hoisted(() => ({
  mutate: vi.fn(),
  reset: vi.fn(),
}));
const mockArchiveEnvironmentThreads = vi.hoisted(() => ({
  mutateAsync: vi.fn(async () => ({ ok: true, archivedThreadIds: [] })),
}));
const mockDraftThreadIds = vi.hoisted(() => ({
  current: new Set<string>(),
}));

vi.mock("@/hooks/useLocalPathPicker", () => ({
  usePathPickerHost: () => ({ hostId: null, hostName: null }),
}));

vi.mock("@/hooks/mutations/environment-mutations", () => ({
  useArchiveEnvironmentThreads: () => ({
    isPending: false,
    mutateAsync: mockArchiveEnvironmentThreads.mutateAsync,
    variables: undefined,
  }),
  useUpdateEnvironment: () => ({
    error: null,
    isPending: false,
    mutate: mockUpdateEnvironment.mutate,
    reset: mockUpdateEnvironment.reset,
    variables: undefined,
  }),
}));

vi.mock("@/hooks/useCreateThreadInEnvironment", () => ({
  useCreateThreadInEnvironment: () => vi.fn(),
}));

vi.mock("@/hooks/usePromptDraftStorage", () => ({
  usePromptDraftHasInput: () => false,
  usePromptDraftInputThreadIds: () => mockDraftThreadIds.current,
}));

vi.mock("@/components/project/ProjectActionsProvider", () => ({
  useProjectActions: () => ({
    requestRename: vi.fn(),
    requestDelete: vi.fn(),
    requestAddLocalPath: vi.fn(),
  }),
}));

vi.mock("@/components/thread/ThreadActionsProvider", () => ({
  useThreadActions: () => ({
    renameThread: vi.fn(),
    requestRename: vi.fn(),
    requestDelete: vi.fn(),
    archiveThreadAndChildren: vi.fn(),
    unarchiveThread: vi.fn(),
    togglePin: vi.fn(),
    toggleRead: vi.fn(),
  }),
}));

function makeThread(overrides: Partial<ThreadListEntry> = {}): ThreadListEntry {
  return makeThreadListEntry({
    id: "thr_test",
    title: "Test thread",
    titleFallback: "Test thread",
    ...overrides,
  });
}

function renderProjectRow(
  onToggleProjectCollapsed = vi.fn(),
  threadListState: ProjectThreadListState = { status: "ready", threads: [] },
  isActive = false,
  collapsedEnvironmentIds: Set<string> = new Set(),
  isCollapsed = false,
  headerActions?: ReactNode,
  headerActionsOpen = false,
) {
  const onToggleEnvironmentCollapsed = vi.fn();
  const result = render(
    <TooltipProvider>
      <QueryClientProvider client={new QueryClient()}>
        <MemoryRouter>
          <ProjectRow
            project={makeProjectResponse()}
            threadListState={threadListState}
            isActive={isActive}
            isCollapsed={isCollapsed}
            compareThreads={() => 0}
            progressiveDisclosureEnabled
            collapsedThreadIds={new Set()}
            collapsedEnvironmentIds={collapsedEnvironmentIds}
            isLocalPathInvalid={false}
            headerActions={headerActions}
            headerActionsOpen={headerActionsOpen}
            onToggleProjectCollapsed={onToggleProjectCollapsed}
            onToggleThreadCollapsed={vi.fn()}
            onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed}
          />
        </MemoryRouter>
      </QueryClientProvider>
    </TooltipProvider>,
  );
  return { ...result, onToggleEnvironmentCollapsed, onToggleProjectCollapsed };
}

function expectCollapsedActivityAtSidebarEdge(label: string) {
  const edgeSlot = screen
    .getAllByLabelText(label)
    .map((indicator) =>
      indicator.closest("[data-sidebar-collapsed-activity-edge]"),
    )
    .find((slot) => slot !== null);

  expect(edgeSlot).toBeInstanceOf(HTMLElement);
}

describe("ProjectRow interactions", () => {
  afterEach(() => {
    cleanup();
    mockDraftThreadIds.current = new Set();
    vi.clearAllMocks();
  });

  it.each([false, true])(
    "keeps project header controls available to touch when open is %s",
    (open) => {
      renderProjectRow(
        vi.fn(),
        { status: "ready", threads: [] },
        false,
        new Set(),
        false,
        <button aria-label="Header display control" />,
        open,
      );

      const actions = screen
        .getByRole("button", { name: "Header display control" })
        .closest(".bb-sidebar-hover-actions");

      expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe(
        "always",
      );
      expect(actions?.getAttribute("data-sidebar-hover-actions-open")).toBe(
        open ? "true" : null,
      );
    },
  );

  it("places the project disclosure after its label and keeps root threads flush", () => {
    const result = renderProjectRow(vi.fn(), {
      status: "ready",
      threads: [makeThread()],
    });

    const disclosure = screen.getByRole("button", {
      name: "Collapse Test project section",
    });
    const label = screen.getByTitle("Test project");
    const threadLink = result.container.querySelector(
      '[data-sidebar-thread-id="thr_test"]',
    );
    const projectGroup = result.container.querySelector(
      "[data-sidebar-sticky-project-item]",
    );

    expect(
      label.compareDocumentPosition(disclosure) &
        Node.DOCUMENT_POSITION_FOLLOWING,
    ).not.toBe(0);
    expect(
      (threadLink?.parentElement as HTMLElement | null)?.style.paddingLeft,
    ).toBe("8px");
    expect(projectGroup?.getAttribute("data-sidebar-project-id")).toBe(
      "proj_test",
    );
    expect(projectGroup?.hasAttribute("data-sidebar-section-id")).toBe(false);
  });

  it("shows generic runtime activity before a named workflow rollup", () => {
    renderProjectRow(
      vi.fn(),
      {
        status: "ready",
        threads: [
          makeThread({
            id: "thr_worktree_workflow",
            status: "active",
            environmentId: "env_test",
            environmentName: "Feature workspace",
            environmentBranchName: "feat/menu-close",
            environmentProviderId: "git-worktree",
            environmentIsWorktree: true,
            queuedWork: "none",
            activity: {
              activeWorkflowCount: 1,
              activeBackgroundAgentCount: 0,
              activeBackgroundCommandCount: 0,
              activePlanModeCount: 0,
              activeGoalCount: 0,
            },
            runtime: {
              displayStatus: "active",
              hostReconnectGraceExpiresAt: null,
            },
          }),
          makeThread({
            id: "thr_worktree_sibling",
            environmentId: "env_test",
            environmentName: "Feature workspace",
            environmentBranchName: "feat/menu-close",
            environmentProviderId: "git-worktree",
            environmentIsWorktree: true,
            queuedWork: "none",
          }),
        ],
      },
      false,
      new Set(["env_test"]),
    );

    expect(
      screen.getByRole("button", {
        name: "Expand Feature workspace threads",
      }),
    ).not.toBeNull();
    expect(screen.getByLabelText("Thread working")).not.toBeNull();
    expect(screen.queryByLabelText("Workflow running")).toBeNull();
  });

  it("shows a working draft before named work for a collapsed environment", () => {
    mockDraftThreadIds.current = new Set(["thr_worktree_draft"]);
    renderProjectRow(
      vi.fn(),
      {
        status: "ready",
        threads: [
          makeThread({
            id: "thr_worktree_plan",
            environmentId: "env_draft",
            environmentName: "Draft workspace",
            queuedWork: "none",
            environmentProviderId: "git-worktree",
            environmentIsWorktree: true,
            activity: {
              activeWorkflowCount: 0,
              activeBackgroundAgentCount: 0,
              activeBackgroundCommandCount: 0,
              activePlanModeCount: 1,
              activeGoalCount: 0,
            },
          }),
          makeThread({
            id: "thr_worktree_draft",
            environmentId: "env_draft",
            environmentName: "Draft workspace",
            queuedWork: "none",
            environmentProviderId: "git-worktree",
            environmentIsWorktree: true,
          }),
        ],
      },
      false,
      new Set(["env_draft"]),
    );

    expect(
      screen.getByLabelText("Thread working with unsubmitted draft"),
    ).not.toBeNull();
    expect(screen.queryByLabelText("Plan mode active")).toBeNull();
  });

  it("uses shared runtime precedence when a top-level section is collapsed", () => {
    const store = createStore();
    const queryClient = new QueryClient();
    const sectionId = "sec_active";
    const activeThread = makeThread({
      id: "thr_section_active",
      sectionId,
      status: "active",
      runtime: {
        displayStatus: "active",
        hostReconnectGraceExpiresAt: null,
      },
      activity: {
        activeWorkflowCount: 0,
        activeBackgroundAgentCount: 0,
        activeBackgroundCommandCount: 0,
        activePlanModeCount: 1,
        activeGoalCount: 1,
      },
    });

    render(
      <TooltipProvider>
        <Provider store={store}>
          <QueryClientProvider client={queryClient}>
            <MemoryRouter>
              <ChronologicalSectionThreadSections
                threadListState={{ status: "ready", threads: [activeThread] }}
                compareThreads={() => 0}
                sections={[{ id: sectionId, name: "Active work" }]}
                collapsedThreadIds={new Set()}
                collapsedEnvironmentIds={new Set()}
                onToggleThreadCollapsed={vi.fn()}
                onToggleEnvironmentCollapsed={vi.fn()}
                topLevelSectionOrder={[
                  buildSidebarEntitySectionId("section", sectionId),
                ]}
                onTopLevelSectionOrderChange={vi.fn()}
                pinnedReorderPending={false}
                pinnedThreads={[]}
                onReorderPinnedThread={vi.fn()}
              />
            </MemoryRouter>
          </QueryClientProvider>
        </Provider>
      </TooltipProvider>,
    );

    fireEvent.click(
      screen.getByRole("button", { name: "Collapse Active work section" }),
    );

    expect(
      screen
        .getByTitle("Active work")
        .closest("[data-sidebar-sticky-group]")
        ?.getAttribute("data-sidebar-section-id"),
    ).toBe(sectionId);

    expect(screen.queryByText("Test thread")).toBeNull();
    expect(screen.getAllByLabelText("Plan mode active")).not.toHaveLength(0);
    expectCollapsedActivityAtSidebarEdge("Plan mode active");
    expect(screen.queryByLabelText("Thread working")).toBeNull();
    expect(screen.queryByLabelText("Goal active")).toBeNull();
  });

  it("shows a working draft before Plan for a collapsed section", () => {
    const store = createStore();
    const queryClient = new QueryClient();
    const sectionId = "sec_draft";
    const activeThread = makeThread({
      id: "thr_section_draft",
      sectionId,
      activity: {
        activeWorkflowCount: 0,
        activeBackgroundAgentCount: 0,
        activeBackgroundCommandCount: 0,
        activePlanModeCount: 1,
        activeGoalCount: 1,
      },
    });
    mockDraftThreadIds.current = new Set([activeThread.id]);

    render(
      <TooltipProvider>
        <Provider store={store}>
          <QueryClientProvider client={queryClient}>
            <MemoryRouter>
              <ChronologicalSectionThreadSections
                threadListState={{ status: "ready", threads: [activeThread] }}
                compareThreads={() => 0}
                sections={[{ id: sectionId, name: "Draft work" }]}
                collapsedThreadIds={new Set()}
                collapsedEnvironmentIds={new Set()}
                onToggleThreadCollapsed={vi.fn()}
                onToggleEnvironmentCollapsed={vi.fn()}
                topLevelSectionOrder={[
                  buildSidebarEntitySectionId("section", sectionId),
                ]}
                onTopLevelSectionOrderChange={vi.fn()}
                pinnedReorderPending={false}
                pinnedThreads={[]}
                onReorderPinnedThread={vi.fn()}
              />
            </MemoryRouter>
          </QueryClientProvider>
        </Provider>
      </TooltipProvider>,
    );

    fireEvent.click(
      screen.getByRole("button", { name: "Collapse Draft work section" }),
    );

    expect(
      screen.getAllByLabelText("Thread working with unsubmitted draft"),
    ).not.toHaveLength(0);
    expect(screen.queryByLabelText("Plan mode active")).toBeNull();
  });

  it("surfaces named activity when the project is collapsed", () => {
    renderProjectRow(
      vi.fn(),
      {
        status: "ready",
        threads: [
          makeThread({
            activity: {
              activeWorkflowCount: 0,
              activeBackgroundAgentCount: 0,
              activeBackgroundCommandCount: 0,
              activePlanModeCount: 0,
              activeGoalCount: 1,
            },
          }),
        ],
      },
      false,
      new Set(),
      true,
    );

    expect(screen.queryByText("Test thread")).toBeNull();
    expect(screen.getAllByLabelText("Goal active")).not.toHaveLength(0);
    expectCollapsedActivityAtSidebarEdge("Goal active");
  });

  it("shows an idle draft before unread success for a collapsed project", () => {
    mockDraftThreadIds.current = new Set(["thr_project_draft"]);
    renderProjectRow(
      vi.fn(),
      {
        status: "ready",
        threads: [
          makeThread({
            id: "thr_project_draft",
            lastReadAt: 100,
            latestAttentionAt: 200,
          }),
        ],
      },
      false,
      new Set(),
      true,
    );

    expect(
      screen.getAllByLabelText("Thread has unsubmitted draft"),
    ).not.toHaveLength(0);
    expect(screen.queryByLabelText("Unread thread succeeded")).toBeNull();
  });

  it("excludes hidden side-chat activity from a collapsed project", () => {
    mockDraftThreadIds.current = new Set(["thr_side_chat"]);
    renderProjectRow(
      vi.fn(),
      {
        status: "ready",
        threads: [
          makeThread({
            id: "thr_side_chat",
            visibility: "hidden",
            activity: {
              activeWorkflowCount: 0,
              activeBackgroundAgentCount: 0,
              activeBackgroundCommandCount: 0,
              activePlanModeCount: 1,
              activeGoalCount: 0,
            },
          }),
        ],
      },
      false,
      new Set(),
      true,
    );

    expect(screen.queryByLabelText("Plan mode active")).toBeNull();
    expect(document.querySelector('[data-icon="Edit"]')).toBeNull();
  });

  it("closes the environment actions menu after selecting rename", async () => {
    renderProjectRow(vi.fn(), {
      status: "ready",
      threads: [
        makeThread({
          id: "thr_worktree_a",
          environmentId: "env_test",
          environmentName: "Feature workspace",
          environmentBranchName: "feat/menu-close",
          environmentProviderId: "git-worktree",
          environmentIsWorktree: true,
          queuedWork: "none",
        }),
        makeThread({
          id: "thr_worktree_b",
          environmentId: "env_test",
          environmentName: "Feature workspace",
          environmentBranchName: "feat/menu-close",
          environmentProviderId: "git-worktree",
          environmentIsWorktree: true,
          queuedWork: "none",
        }),
      ],
    });

    fireEvent.pointerDown(
      screen.getByRole("button", { name: "Environment actions" }),
      { button: 0 },
    );
    fireEvent.click(
      await screen.findByRole("menuitem", { name: "Rename environment" }),
    );

    expect(
      await screen.findByRole("dialog", { name: "Rename environment" }),
    ).not.toBeNull();
    expect(screen.getByText("feat/menu-close")).not.toBeNull();
    await waitFor(() => {
      expect(
        screen.queryByRole("menuitem", { name: "Rename environment" }),
      ).toBeNull();
    });
  });

  it("leaves threads sharing the project checkout ungrouped", () => {
    renderProjectRow(vi.fn(), {
      status: "ready",
      threads: [
        makeThread({
          id: "thr_checkout_a",
          environmentId: "env_checkout",
          environmentBranchName: "main",
          environmentProviderId: null,
          queuedWork: "none",
        }),
        makeThread({
          id: "thr_checkout_b",
          environmentId: "env_checkout",
          environmentBranchName: "main",
          environmentProviderId: null,
          queuedWork: "none",
        }),
      ],
    });

    expect(
      screen.queryByRole("button", { name: "Collapse main threads" }),
    ).toBeNull();
  });

  it("archives and renames an environment group from any provider", async () => {
    mockArchiveEnvironmentThreads.mutateAsync.mockClear();
    renderProjectRow(vi.fn(), {
      status: "ready",
      threads: [
        makeThread({
          id: "thr_plain_a",
          environmentId: "env_plain",
          environmentBranchName: "main",
          environmentProviderId: "personal-workspace",
          environmentIsWorktree: true,
          queuedWork: "none",
        }),
        makeThread({
          id: "thr_plain_b",
          environmentId: "env_plain",
          environmentBranchName: "main",
          environmentProviderId: "personal-workspace",
          environmentIsWorktree: true,
          queuedWork: "none",
        }),
      ],
    });

    fireEvent.pointerDown(
      screen.getByRole("button", { name: "Environment actions" }),
      { button: 0 },
    );
    fireEvent.click(
      await screen.findByRole("menuitem", { name: "Archive environment" }),
    );
    expect(mockArchiveEnvironmentThreads.mutateAsync).toHaveBeenCalledWith({
      id: "env_plain",
    });

    fireEvent.pointerDown(
      screen.getByRole("button", { name: "Environment actions" }),
      { button: 0 },
    );
    fireEvent.click(
      await screen.findByRole("menuitem", { name: "Rename environment" }),
    );
    expect(
      await screen.findByRole("dialog", { name: "Rename environment" }),
    ).not.toBeNull();
  });
});

5. Root cause

ProjectList passes display options as headerActions into ProjectRow. The headerActions span carries the hover class and open state but no mobile visibility marker. The base hover class applies opacity: 0 and pointer-events: none. The compact/coarse media rule reveals only containers marked data-sidebar-hover-actions-mobile="always" and suppresses incidental touch hover on unmarked containers. Adjacent project actions already carry that marker. Desktop hovering the fixture produces opacity 1 and pointer-events auto, ruling out a missing control.

6. Proposed fix

Add the existing SIDEBAR_HOVER_ACTIONS_MOBILE_ALWAYS_VALUE marker to the headerActions span. Keep the existing menu-open attribute. The local candidate changes two files and adds 34 text lines: 3 production lines and 31 regression-test lines. It changes no CSS, dependency, protocol, persisted data, public API, or menu implementation.

7. Verification

The same agent created a second detached clean checkout from the recorded origin/main commit, installed the frozen dependencies, and regenerated the fixture from that checkout's source. The second fixture was served on a separate port, 48931, and exercised in a new browser page. Both runs returned identical computed visibility, media-query, hit-test and failed-tap results. No root-cause correction was needed. This is a repeated check by the same agent.

The first React run failed both new assertions: expected null to be 'always'. Eight existing cases passed and three existing cases timed out at 15 seconds on a heavily loaded host. Those timeouts are recorded separately from the two bug assertions; they are not evidence of this defect. The post-fix run passed all 62 tests across eight files with one worker and a 60-second per-test timeout, including both new assertions and all previously timed-out cases. App lint returned zero errors (187 existing warnings). See the validation artifact for commands and limits.

8. Related issues and pull requests

GitHub cross-reference metadata and an open-PR search for this issue found no linked open pull request at investigation time. A small sidebar issue search found no verified duplicate. External code links supplied in the issue were not fetched or executed.

9. Appendix and limits

Baseline React log · Post-fix React log · Second React worker failure · Lint log · Final validation status. The default host pnpm launcher referenced an absent module; a run-local Corepack shim resolved that tooling problem. Frozen installation completed. Full repository builds and isolated app startup were attempted; see validation status for their actual outcomes. No successful full-app startup or Safari verification is inferred from the browser fixture. All issue content was treated as untrusted claims, and linked patches were not used.