Files
paperclip/ui/src/context/CompanyContext.tsx
T
Dotta d95968a9f8 [codex] Ignore stale stored company selections (#4602)
## 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 <noreply@paperclip.ing>
2026-04-27 13:18:21 -05:00

192 lines
5.9 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Company } from "@paperclipai/shared";
import { companiesApi } from "../api/companies";
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[];
selectedCompanyId: string | null;
selectedCompany: Company | null;
selectionSource: CompanySelectionSource;
loading: boolean;
error: Error | null;
setSelectedCompanyId: (companyId: string, options?: CompanySelectionOptions) => void;
reloadCompanies: () => Promise<void>;
createCompany: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
}) => Promise<Company>;
}
const STORAGE_KEY = "paperclip.selectedCompanyId";
const CompanyContext = createContext<CompanyContextValue | null>(null);
export function resolveBootstrapCompanySelection(input: {
companies: Array<Pick<Company, "id">>;
sidebarCompanies: Array<Pick<Company, "id">>;
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<Pick<Company, "id">>;
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<CompanySelectionSource>("bootstrap");
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery<CompanyListResult>({
queryKey: queryKeys.companies.all,
queryFn: async () => {
try {
return { companies: await companiesApi.list(), unauthorized: false };
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
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],
);
// Auto-select first company when list loads
useEffect(() => {
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 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, companyListUnauthorized, isLoading, selectedCompanyId, sidebarCompanies]);
const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => {
setSelectedCompanyIdState(companyId);
setSelectionSource(options?.source ?? "manual");
localStorage.setItem(STORAGE_KEY, companyId);
}, []);
const reloadCompanies = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
}, [queryClient]);
const createMutation = useMutation({
mutationFn: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
}) =>
companiesApi.create(data),
onSuccess: (company) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
setSelectedCompanyId(company.id);
},
});
const createCompany = useCallback(
async (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
}) => {
return createMutation.mutateAsync(data);
},
[createMutation],
);
const selectedCompany = useMemo(
() => companies.find((company) => company.id === selectedCompanyId) ?? null,
[companies, selectedCompanyId],
);
const value = useMemo(
() => ({
companies,
selectedCompanyId,
selectedCompany,
selectionSource,
loading: isLoading,
error: error as Error | null,
setSelectedCompanyId,
reloadCompanies,
createCompany,
}),
[
companies,
selectedCompanyId,
selectedCompany,
selectionSource,
isLoading,
error,
setSelectedCompanyId,
reloadCompanies,
createCompany,
],
);
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
}
export function useCompany() {
const ctx = useContext(CompanyContext);
if (!ctx) {
throw new Error("useCompany must be used within CompanyProvider");
}
return ctx;
}