diff --git a/ui/src/components/InlineEntitySelector.test.tsx b/ui/src/components/InlineEntitySelector.test.tsx
new file mode 100644
index 00000000..f127f41c
--- /dev/null
+++ b/ui/src/components/InlineEntitySelector.test.tsx
@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { InlineEntitySelector } from "./InlineEntitySelector";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("InlineEntitySelector", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("keeps handled search navigation keys inside the popover", async () => {
+ const root = createRoot(container);
+ const onChange = vi.fn();
+ const documentKeyDown = vi.fn();
+ document.addEventListener("keydown", documentKeyDown);
+
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+
+ const trigger = container.querySelector("button") as HTMLButtonElement | null;
+ expect(trigger).not.toBeNull();
+
+ await act(async () => {
+ trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ const searchInput = document.querySelector('input[placeholder="Search assignees..."]') as HTMLInputElement | null;
+ expect(searchInput).not.toBeNull();
+ searchInput?.focus();
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ searchInput?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
+ });
+
+ expect(documentKeyDown).not.toHaveBeenCalled();
+
+ await act(async () => {
+ searchInput?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
+ });
+
+ expect(documentKeyDown).not.toHaveBeenCalled();
+ expect(onChange).toHaveBeenCalledWith("agent:agent-1");
+
+ document.removeEventListener("keydown", documentKeyDown);
+ act(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx
index 6bbcf649..0ac378b5 100644
--- a/ui/src/components/InlineEntitySelector.tsx
+++ b/ui/src/components/InlineEntitySelector.tsx
@@ -1,4 +1,4 @@
-import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
@@ -29,6 +29,8 @@ interface InlineEntitySelectorProps {
openOnFocus?: boolean;
}
+const EMPTY_RECENT_OPTION_IDS: string[] = [];
+
export const InlineEntitySelector = forwardRef(
function InlineEntitySelector(
{
@@ -43,7 +45,7 @@ export const InlineEntitySelector = forwardRef(null);
const shouldPreventCloseAutoFocusRef = useRef(false);
const isPointerDownRef = useRef(false);
@@ -72,11 +75,17 @@ export const InlineEntitySelector = forwardRef option.id === value) ?? null;
+ const setHighlightedIndexValue = useCallback((next: number | ((current: number) => number)) => {
+ const resolved = typeof next === "function" ? next(highlightedIndexRef.current) : next;
+ highlightedIndexRef.current = resolved;
+ setHighlightedIndex(resolved);
+ }, []);
+
useEffect(() => {
if (!open) return;
const selectedIndex = filteredOptions.findIndex((option) => option.id === value);
- setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
- }, [filteredOptions, open, value]);
+ setHighlightedIndexValue(selectedIndex >= 0 ? selectedIndex : 0);
+ }, [filteredOptions, open, setHighlightedIndexValue, value]);
const commitSelection = (index: number, moveNext: boolean) => {
const option = filteredOptions[index] ?? filteredOptions[0];
@@ -153,14 +162,16 @@ export const InlineEntitySelector = forwardRef {
if (event.key === "ArrowDown") {
event.preventDefault();
- setHighlightedIndex((current) =>
+ event.stopPropagation();
+ setHighlightedIndexValue((current) =>
filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
- setHighlightedIndex((current) => {
+ event.stopPropagation();
+ setHighlightedIndexValue((current) => {
if (filteredOptions.length === 0) return 0;
return current <= 0 ? filteredOptions.length - 1 : current - 1;
});
@@ -168,16 +179,19 @@ export const InlineEntitySelector = forwardRef setHighlightedIndex(index)}
+ onMouseEnter={() => setHighlightedIndexValue(index)}
onClick={() => commitSelection(index, true)}
>
{renderOption ? renderOption(option, isSelected) : {option.label}}
diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx
index 5e68ad3d..d31b6e33 100644
--- a/ui/src/context/CompanyContext.test.tsx
+++ b/ui/src/context/CompanyContext.test.tsx
@@ -1,10 +1,64 @@
-import { describe, expect, it } from "vitest";
-import { resolveBootstrapCompanySelection, shouldClearStoredCompanySelection } from "./CompanyContext";
+// @vitest-environment jsdom
+
+import { act, useEffect } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Company } from "@paperclipai/shared";
+import { queryKeys } from "../lib/queryKeys";
+import {
+ CompanyProvider,
+ resolveBootstrapCompanySelection,
+ shouldClearStoredCompanySelection,
+ useCompany,
+} from "./CompanyContext";
+
+const mockCompaniesApi = vi.hoisted(() => ({
+ list: vi.fn(),
+ create: vi.fn(),
+}));
+
+vi.mock("../api/companies", () => ({
+ companiesApi: mockCompaniesApi,
+}));
const activeCompany = { id: "company-1" };
const secondActiveCompany = { id: "company-2" };
const archivedCompany = { id: "archived-company" };
+function makeCompany(id: string): Company {
+ return {
+ id,
+ name: "Paperclip",
+ description: null,
+ status: "active",
+ pauseReason: null,
+ pausedAt: null,
+ issuePrefix: "PAP",
+ issueCounter: 1,
+ budgetMonthlyCents: 0,
+ spentMonthlyCents: 0,
+ requireBoardApprovalForNewAgents: false,
+ feedbackDataSharingEnabled: false,
+ feedbackDataSharingConsentAt: null,
+ feedbackDataSharingConsentByUserId: null,
+ feedbackDataSharingTermsVersion: null,
+ brandColor: null,
+ logoAssetId: null,
+ logoUrl: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: string | null) => void }) {
+ const { selectedCompanyId } = useCompany();
+ useEffect(() => {
+ onSelectedCompanyId(selectedCompanyId);
+ }, [onSelectedCompanyId, selectedCompanyId]);
+ return ;
+}
+
describe("resolveBootstrapCompanySelection", () => {
it("does not expose a stale stored company id before companies load", () => {
expect(resolveBootstrapCompanySelection({
@@ -69,3 +123,72 @@ describe("shouldClearStoredCompanySelection", () => {
})).toBe(true);
});
});
+
+describe("CompanyProvider", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ localStorage.clear();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ queryClient.clear();
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ it("does not expose a stale stored company id before companies load", async () => {
+ localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
+ mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
+ const seen: Array = [];
+
+ await act(async () => {
+ root.render(
+
+
+ seen.push(companyId)} />
+
+ ,
+ );
+ });
+
+ expect(seen).toEqual([null]);
+ });
+
+ it("replaces a stale stored company id with the first loaded company", async () => {
+ localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
+ queryClient.setQueryData(queryKeys.companies.all, {
+ companies: [makeCompany("company-1")],
+ unauthorized: false,
+ });
+ mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
+ const seen: Array = [];
+
+ await act(async () => {
+ root.render(
+
+
+ seen.push(companyId)} />
+
+ ,
+ );
+ });
+
+ expect(seen).toEqual([null, "company-1"]);
+ expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
+ });
+});