From d95968a9f8ee79f527327bd6fb864a4325dd6fcb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:18:21 -0500 Subject: [PATCH] [codex] Ignore stale stored company selections (#4602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board UI is the operator’s control surface for selecting the active company > - A company id stored in localStorage can become stale across resets, imports, or deleted companies > - Exposing that stale id before companies load can briefly put downstream UI in an invalid company scope > - This pull request defers selected-company exposure until the loaded company list validates the stored id > - The benefit is a cleaner company-selection bootstrap path and fewer transient invalid API requests ## What Changed - Initialized `CompanyProvider` selection as `null` until companies finish loading. - Reused a stored company id only when it exists in the loaded selectable company list. - Cleared storage and selected state when no companies are available. - Added jsdom regression coverage for stale stored ids before and after company loading. ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/context/CompanyContext.test.tsx` ## Risks - Low risk. The change only affects selection bootstrap and keeps valid stored selections intact. - There may be a slightly longer initial `null` selected-company state while the company list is loading. > 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 coding agent, tool-enabled terminal/GitHub workflow, reasoning mode active. Context window not exposed in this environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/context/CompanyContext.test.tsx | 71 ++++++++++++++++++++++++++ ui/src/context/CompanyContext.tsx | 65 ++++++++++++++++++----- 2 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 ui/src/context/CompanyContext.test.tsx diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx new file mode 100644 index 00000000..5e68ad3d --- /dev/null +++ b/ui/src/context/CompanyContext.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { resolveBootstrapCompanySelection, shouldClearStoredCompanySelection } from "./CompanyContext"; + +const activeCompany = { id: "company-1" }; +const secondActiveCompany = { id: "company-2" }; +const archivedCompany = { id: "archived-company" }; + +describe("resolveBootstrapCompanySelection", () => { + it("does not expose a stale stored company id before companies load", () => { + expect(resolveBootstrapCompanySelection({ + companies: [], + sidebarCompanies: [], + selectedCompanyId: null, + storedCompanyId: "stale-company", + })).toBeNull(); + }); + + it("replaces a stale stored company id with the first loaded company", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: null, + storedCompanyId: "stale-company", + })).toBe("company-1"); + }); + + it("keeps a valid selected company ahead of stored bootstrap state", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: "company-1", + storedCompanyId: "stale-company", + })).toBe("company-1"); + }); + + it("keeps a valid stored company id instead of falling back to the first company", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany, secondActiveCompany], + sidebarCompanies: [activeCompany, secondActiveCompany], + selectedCompanyId: null, + storedCompanyId: "company-2", + })).toBe("company-2"); + }); + + it("uses selectable sidebar companies before archived companies", () => { + expect(resolveBootstrapCompanySelection({ + companies: [archivedCompany, activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: null, + storedCompanyId: "archived-company", + })).toBe("company-1"); + }); +}); + +describe("shouldClearStoredCompanySelection", () => { + it("does not clear the stored company selection during an unauthorized company list response", () => { + expect(shouldClearStoredCompanySelection({ + companies: [], + isLoading: false, + unauthorized: true, + })).toBe(false); + }); + + it("clears the stored company selection when an authorized company list is empty", () => { + expect(shouldClearStoredCompanySelection({ + companies: [], + isLoading: false, + unauthorized: false, + })).toBe(true); + }); +}); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 86afa175..18a043f0 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -14,6 +14,7 @@ import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import type { CompanySelectionSource } from "../lib/company-selection"; type CompanySelectionOptions = { source?: CompanySelectionSource }; +type CompanyListResult = { companies: Company[]; unauthorized: boolean }; interface CompanyContextValue { companies: Company[]; @@ -35,25 +36,55 @@ const STORAGE_KEY = "paperclip.selectedCompanyId"; const CompanyContext = createContext(null); +export function resolveBootstrapCompanySelection(input: { + companies: Array>; + sidebarCompanies: Array>; + selectedCompanyId: string | null; + storedCompanyId: string | null; +}) { + if (input.companies.length === 0) return null; + + const selectableCompanies = input.sidebarCompanies.length > 0 + ? input.sidebarCompanies + : input.companies; + if (input.selectedCompanyId && selectableCompanies.some((company) => company.id === input.selectedCompanyId)) { + return input.selectedCompanyId; + } + if (input.storedCompanyId && selectableCompanies.some((company) => company.id === input.storedCompanyId)) { + return input.storedCompanyId; + } + return selectableCompanies[0]?.id ?? null; +} + +export function shouldClearStoredCompanySelection(input: { + companies: Array>; + isLoading: boolean; + unauthorized: boolean; +}) { + return !input.isLoading && !input.unauthorized && input.companies.length === 0; +} + export function CompanyProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [selectionSource, setSelectionSource] = useState("bootstrap"); - const [selectedCompanyId, setSelectedCompanyIdState] = useState(() => localStorage.getItem(STORAGE_KEY)); + const [selectedCompanyId, setSelectedCompanyIdState] = useState(null); - const { data: companies = [], isLoading, error } = useQuery({ + const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery({ queryKey: queryKeys.companies.all, queryFn: async () => { try { - return await companiesApi.list(); + return { companies: await companiesApi.list(), unauthorized: false }; } catch (err) { if (err instanceof ApiError && err.status === 401) { - return []; + return { companies: [], unauthorized: true }; } throw err; } }, retry: false, }); + const companies = companiesResult.companies; + const companyListUnauthorized = companiesResult.unauthorized; const sidebarCompanies = useMemo( () => companies.filter((company) => company.status !== "archived"), [companies], @@ -61,18 +92,28 @@ export function CompanyProvider({ children }: { children: ReactNode }) { // Auto-select first company when list loads useEffect(() => { - if (companies.length === 0) return; + if (isLoading) return; + if (companies.length === 0) { + if (shouldClearStoredCompanySelection({ companies, isLoading: false, unauthorized: companyListUnauthorized })) { + if (selectedCompanyId !== null) { + setSelectedCompanyIdState(null); + } + localStorage.removeItem(STORAGE_KEY); + } + return; + } - const selectableCompanies = sidebarCompanies.length > 0 ? sidebarCompanies : companies; - const stored = localStorage.getItem(STORAGE_KEY); - if (stored && selectableCompanies.some((c) => c.id === stored)) return; - if (selectedCompanyId && selectableCompanies.some((c) => c.id === selectedCompanyId)) return; - - const next = selectableCompanies[0]!.id; + const next = resolveBootstrapCompanySelection({ + companies, + sidebarCompanies, + selectedCompanyId, + storedCompanyId: localStorage.getItem(STORAGE_KEY), + }); + if (next === null || next === selectedCompanyId) return; setSelectedCompanyIdState(next); setSelectionSource("bootstrap"); localStorage.setItem(STORAGE_KEY, next); - }, [companies, selectedCompanyId, sidebarCompanies]); + }, [companies, companyListUnauthorized, isLoading, selectedCompanyId, sidebarCompanies]); const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => { setSelectedCompanyIdState(companyId);