d95968a9f8
## 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>
192 lines
5.9 KiB
TypeScript
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;
|
|
}
|