Files
PaperClipAI/ui/src/lib/company-selection.test.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

141 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
resolveArchivedCompanyBounce,
resolveCompanyArchiveDeparture,
shouldSyncCompanySelectionFromRoute,
} from "./company-selection";
describe("shouldSyncCompanySelectionFromRoute", () => {
it("does not resync when selection already matches the route", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "pap",
}),
).toBe(false);
});
it("defers route sync while a manual company switch is in flight", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "manual",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(false);
});
it("syncs back to the route company for non-manual mismatches", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(true);
});
});
describe("resolveArchivedCompanyBounce", () => {
const archived = { id: "old", name: "Old Co", issuePrefix: "OLD", status: "archived" };
const active = { id: "pap", name: "Paperclip", issuePrefix: "PAP", status: "active" };
const other = { id: "ret", name: "Retail", issuePrefix: "RET", status: "active" };
it("bounces a cold arrival on an archived company's URL to the active selection", () => {
expect(
resolveArchivedCompanyBounce({
matchedCompany: archived,
selectedCompanyId: "pap",
companies: [archived, active, other],
}),
).toEqual(active);
});
it("bounces to the first active company when nothing is selected", () => {
expect(
resolveArchivedCompanyBounce({
matchedCompany: archived,
selectedCompanyId: null,
companies: [archived, other],
}),
).toEqual(other);
});
it("does not bounce a deliberate visit where the archived company is already selected", () => {
expect(
resolveArchivedCompanyBounce({
matchedCompany: archived,
selectedCompanyId: "old",
companies: [archived, active],
}),
).toBeNull();
});
it("does not bounce active companies or when every company is archived", () => {
expect(
resolveArchivedCompanyBounce({
matchedCompany: active,
selectedCompanyId: null,
companies: [archived, active],
}),
).toBeNull();
expect(
resolveArchivedCompanyBounce({
matchedCompany: archived,
selectedCompanyId: null,
companies: [archived],
}),
).toBeNull();
});
});
describe("resolveCompanyArchiveDeparture", () => {
const justArchived = { id: "old", name: "Old Co", issuePrefix: "OLD", status: "archived" };
const active = { id: "pap", name: "Paperclip", issuePrefix: "PAP", status: "active" };
const alsoArchived = { id: "ret", name: "Retail", issuePrefix: "RET", status: "archived" };
const portfolioUrl = "https://my.paperclip.app/orgs?manage=1";
it("switches to another active company when one exists, even on cloud", () => {
expect(
resolveCompanyArchiveDeparture({
archivedCompanyId: "old",
companies: [justArchived, alsoArchived, active],
cloudPortfolioUrl: portfolioUrl,
}),
).toEqual({ kind: "company", company: active });
});
it("never departs into the company that was just archived, whatever its cached status", () => {
// The caller's company list is pre-invalidation, so the archived
// company can still read "active" there.
expect(
resolveCompanyArchiveDeparture({
archivedCompanyId: "old",
companies: [{ ...justArchived, status: "active" }],
cloudPortfolioUrl: null,
}),
).toEqual({ kind: "companies_list" });
});
it("leaves for the Cloud portfolio when no active company remains", () => {
expect(
resolveCompanyArchiveDeparture({
archivedCompanyId: "old",
companies: [justArchived, alsoArchived],
cloudPortfolioUrl: portfolioUrl,
}),
).toEqual({ kind: "cloud_portfolio", url: portfolioUrl });
});
it("falls back to the companies list on self-hosted instances", () => {
expect(
resolveCompanyArchiveDeparture({
archivedCompanyId: "old",
companies: [justArchived],
cloudPortfolioUrl: null,
}),
).toEqual({ kind: "companies_list" });
});
});