mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## 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
141 lines
4.4 KiB
TypeScript
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" });
|
|
});
|
|
});
|