Files
PaperClipAI/ui/src/lib/company-selection.ts
T
Devin Foley 64faf0ae90 fix(ui): leave the archived company's settings after archiving it (#13846)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A person can archive a company from that company's settings page.
> - After the archive, the page stays on the archived company. The only
visible change is the button text "Already archived".
> - It is unclear that anything happened, and the user has no next step
on screen.
> - This pull request navigates away after a successful archive: to
another active company, to the Cloud portfolio when no active company
remains on a managed instance, or to the companies list when
self-hosted.
> - The benefit is a clear outcome: the user sees where they are now and
a toast that names what happened.

## Linked Issues or Issue Description

No public issue exists. Description follows the enhancement template:

**What existing behavior does this improve?**

The archive action on the company settings page. The mutation works, but
the view stays on the archived company and gives no feedback beyond a
disabled button.

**Subsystem affected**

UI: company settings page, company selection helpers, cloud links.

**Current behavior**

Archive succeeds. The button changes to "Already archived". The user
stays on the archived company's settings. On a single-company instance
nothing else changes.

**Proposed behavior**

After a successful archive, the app departs: another active company's
dashboard with a toast; the Cloud portfolio's manage view when no active
company remains on a managed instance; the companies list when
self-hosted with nothing active, because that list shows the archived
state and owns the unarchive action.

**Reason and benefit**

Staying on the archived company reads as "nothing happened". Leaving to
a live surface makes the outcome clear and gives the user their next
step.

## What Changed

- `ui/src/lib/company-selection.ts`: new pure
`resolveCompanyArchiveDeparture` helper. Another active company wins;
the just-archived company is excluded by id, so a stale cached status
cannot select it. Cloud portfolio is the fallback on managed instances;
the companies list is the final fallback.
- `ui/src/lib/cloudLinks.ts`: new `cloudPortfolioManageUrl`
(`/orgs?manage=1`). The manage view matters: the plain launchpad
auto-forwards a solo user back into their one openable stack — the page
this navigation is escaping.
- `ui/src/pages/CompanySettings.tsx`: the archive mutation now departs
on success — toast + `navigate` for in-app destinations, a top-level
navigation for the Cloud portfolio — instead of only setting the
selected company id.
- `ui/src/pages/CompanySettingsRenameHint.test.tsx`: render harness
wraps the page in a `MemoryRouter` for the new router dependency.

## Verification

- `pnpm exec vitest run src/lib/company-selection.test.ts
src/lib/cloudLinks.test.ts src/pages/CompanySettingsRenameHint.test.tsx
src/pages/CompanySettings.test.tsx` — 22 tests pass.
- New unit tests cover: active sibling wins (also on cloud), the
just-archived company is never the destination even with a stale cached
status, cloud portfolio fallback, self-hosted companies-list fallback,
and the portfolio URL helper (null base included).
- `pnpm exec tsc --noEmit` in `ui/` is clean after the plugin SDK build.

## Risks

- Low risk. The archive API call is unchanged; only post-success
navigation is new. The toast uses the optional actions hook, so surfaces
without a ToastProvider stay safe.
- On Cloud, the portfolio navigation is a full top-level navigation, so
the query-cache invalidation for the departed document is skipped
intentionally.

## Model Used

- Claude Fable 5 (`claude-fable-5`), via Claude Code CLI, extended
thinking and tool use enabled.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-22 20:36:20 -07:00

89 lines
3.6 KiB
TypeScript

export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
interface BounceCandidateCompany {
id: string;
name: string;
issuePrefix: string;
status: string;
}
/**
* Decides whether a navigation that landed on an archived company's URL
* should bounce to an active company instead of dwelling in the archive.
*
* Stale state deposits users into archived companies long after archiving:
* remembered last-visited paths, browser history, bookmarks, and restored
* tabs all outlive the archive. Rendering those pages is safe, but it is
* never where the user wants to *be* — the sidebar does not even list the
* company. Cold arrivals therefore bounce to an active company.
*
* Deliberate visits still work: when the archived company is already the
* selection (the user chose it from the companies list), there is no
* bounce, so its settings and unarchive flows stay reachable. When no
* active company exists there is nowhere better to go, so the archive
* renders rather than bouncing into a dead end.
*/
export function resolveArchivedCompanyBounce(params: {
matchedCompany: BounceCandidateCompany | null;
selectedCompanyId: string | null;
companies: BounceCandidateCompany[];
}): BounceCandidateCompany | null {
const { matchedCompany, selectedCompanyId, companies } = params;
if (!matchedCompany || matchedCompany.status !== "archived") return null;
if (selectedCompanyId === matchedCompany.id) return null;
const selectedActive = companies.find(
(company) => company.id === selectedCompanyId && company.status !== "archived",
);
return selectedActive ?? companies.find((company) => company.status !== "archived") ?? null;
}
export type CompanyArchiveDeparture =
| { kind: "company"; company: BounceCandidateCompany }
| { kind: "cloud_portfolio"; url: string }
| { kind: "companies_list" };
/**
* Decides where the app should take the user after they archive a company
* from its own settings page. Staying put is never right: the page they are
* on belongs to the company they just archived, and the only visible change
* would be the archive button going inert.
*
* Another active company wins — the user still has somewhere to work, so
* switch there (mirroring the cold-arrival bounce above). With no active
* company left on a Cloud instance, the whole organization is on its way to
* being archived by the control plane, so leave for the Cloud portfolio's
* manage view. Self-hosted with nothing active falls back to the companies
* list, which shows the archived state and owns the unarchive affordance.
*/
export function resolveCompanyArchiveDeparture(params: {
archivedCompanyId: string;
companies: BounceCandidateCompany[];
cloudPortfolioUrl: string | null;
}): CompanyArchiveDeparture {
const { archivedCompanyId, companies, cloudPortfolioUrl } = params;
const nextCompany = companies.find(
(company) => company.id !== archivedCompanyId && company.status !== "archived",
);
if (nextCompany) return { kind: "company", company: nextCompany };
if (cloudPortfolioUrl) return { kind: "cloud_portfolio", url: cloudPortfolioUrl };
return { kind: "companies_list" };
}
export function shouldSyncCompanySelectionFromRoute(params: {
selectionSource: CompanySelectionSource;
selectedCompanyId: string | null;
routeCompanyId: string;
}): boolean {
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
if (selectedCompanyId === routeCompanyId) return false;
// Let manual company switches finish their remembered-path navigation first.
if (selectionSource === "manual" && selectedCompanyId) {
return false;
}
return true;
}