From a09d7dcc0664bdcfa8abbfd731789dc86eef058c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 12 Aug 2026 21:02:12 -0700 Subject: [PATCH] feat(ui): bounce cold arrivals off archived company URLs, add Unarchive (#11302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Archiving a company hides it from the sidebar switcher, but remembered last-visited paths, browser history, bookmarks, and restored tabs keep depositing users onto its URLs long after archiving > - Since the selection ping-pong fix (#11300) those arrivals render, but the user is stranded inside a workspace the sidebar refuses to show — and unarchiving had no UI anywhere, so the only way back was a hand-typed settings URL > - This pull request bounces cold arrivals at archived company URLs to an active company (with a toast naming why), lets deliberate visits stick, and adds an Unarchive action to the companies list > - The benefit is that stale URLs stop stranding users in retired workspaces, and archived companies become restorable from the one page that still lists them ## Linked Issues or Issue Description Follow-up to #11300. No existing issue for the remaining gap; description follows the enhancement template: **What happened?** After #11300, opening an archived company's URL (stale tab, history, bookmark, remembered path) renders that company's pages — but the sidebar switcher does not list it, so the user is stranded in a workspace they retired, and every stale URL pulls them back in. Separately, unarchiving a company has no UI: the archive button lives in company settings, which becomes unreachable through normal navigation once the company is archived. **Expected behavior** Arriving cold at an archived company's URL lands the user in an active workspace, with a toast explaining the redirect. Explicitly choosing the archived company (from the companies list) still works, so its pages remain reachable. Archived companies can be restored from the companies list. **Steps to reproduce** 1. Create two companies; archive one. 2. Open `/{archivedPrefix}/dashboard` directly — before: renders the archived workspace with no sidebar presence; after: bounces to the active company's dashboard with a toast. 3. On the companies list, open the archived company's row menu — before: no restore action anywhere; after: Unarchive. ## What Changed - `ui/src/lib/company-selection.ts`: `resolveArchivedCompanyBounce` — pure policy: bounce when the URL names an archived company that is not the current selection and an active company exists; prefer the currently selected active company as the destination. - `ui/src/components/Layout.tsx`: the route-sync effect applies the bounce (toast + selection + `replace` navigation) before syncing selection from the route. - `ui/src/pages/Companies.tsx`: Unarchive action (`PATCH status: "active"`) in the row menu for archived companies. - Tests: unit cases for the bounce policy; the e2e now drives all three behaviors (direct-load bounce with toast, re-arrival bounce, deliberate visit sticks) on top of the existing crash regression. ## Verification - `pnpm vitest run src/lib/company-selection.test.ts src/context/CompanyContext.test.tsx src/pages/Companies.test.tsx` in `ui/` — 20 tests pass. - `npx playwright test --config tests/e2e/playwright.config.ts archived-company-url` — passes, covering bounce, toast, and deliberate-visit paths. - `pnpm typecheck` in `ui/` — clean. ## Risks Low risk. The bounce only fires for archived-company URLs when the archived company is not already selected and an active company exists; all-archived instances render as before. Deliberate selection from the companies list is unaffected (selection equals the matched company, so no bounce). Unarchive reuses the existing `PATCH /api/companies/:id` status transition the server already supports. ## Model Used - Claude (Anthropic), Claude Fable 5 (`claude-fable-5`), via Claude Code CLI with extended thinking and tool use (code search, edit, test execution, Playwright e2e). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- tests/e2e/archived-company-url.spec.ts | 63 ++++++++++++++++---------- ui/src/components/Layout.test.tsx | 4 ++ ui/src/components/Layout.tsx | 27 ++++++++++- ui/src/lib/company-selection.test.ts | 55 +++++++++++++++++++++- ui/src/lib/company-selection.ts | 38 ++++++++++++++++ ui/src/pages/Companies.tsx | 23 ++++++++++ 6 files changed, 185 insertions(+), 25 deletions(-) diff --git a/tests/e2e/archived-company-url.spec.ts b/tests/e2e/archived-company-url.spec.ts index 87d362a737..52cd5ea116 100644 --- a/tests/e2e/archived-company-url.spec.ts +++ b/tests/e2e/archived-company-url.spec.ts @@ -1,16 +1,18 @@ import { expect, test, type Page } from "@playwright/test"; /** - * Regression: landing on an archived company's URL crashed the app with React - * error #185. The Layout route-sync matched the URL against the full company - * list and selected the archived company; the CompanyProvider bootstrap - * resolver only accepted non-archived companies and immediately re-selected - * an active one; the two effects ping-ponged the selection until React blew - * the nested-update limit and unmounted the tree. + * Archived-company URL handling. * - * Field shape: a workspace whose seeded primary company was archived — every - * revisit of its remembered `/PREFIX/...` URL (first load and back/forward - * navigations alike) produced a blank page. + * Regression (React #185): landing on an archived company's URL crashed the + * app — the Layout route-sync selected the archived company while the + * CompanyProvider bootstrap resolver rejected it, and the two effects + * ping-ponged the selection until React blew the nested-update limit. + * + * Policy: stale state (remembered paths, browser history, bookmarks, + * restored tabs) keeps depositing users into archived companies long after + * archiving, so a cold arrival bounces to an active company with a toast. + * A deliberate visit — selecting the archived company from the companies + * list — sticks, so its pages stay reachable. */ async function createCompany(page: Page, name: string): Promise<{ id: string; prefix: string }> { @@ -25,7 +27,7 @@ function collectFatalErrors(page: Page): string[] { page.on("pageerror", (err) => { fatal.push(`PAGEERROR: ${err.message}`); }); - page.on("console", async (msg) => { + page.on("console", (msg) => { if (msg.type() !== "error") return; const text = msg.text(); if (!/App shell crashed|Page render failed|Minified React error #185|Maximum update depth/.test(text)) { @@ -36,7 +38,7 @@ function collectFatalErrors(page: Page): string[] { return fatal; } -test("landing on an archived company's URL does not crash the app", async ({ page }) => { +test("archived company URLs bounce cold arrivals and honor deliberate visits", async ({ page }) => { const fatal = collectFatalErrors(page); const active = await createCompany(page, "Archived Loop Active"); @@ -46,22 +48,37 @@ test("landing on an archived company's URL does not crash the app", async ({ pag }); expect(archiveRes.ok(), `archive failed ${archiveRes.status()}: ${await archiveRes.text()}`).toBe(true); - // Field repro #1: a fresh load straight onto the archived company's - // remembered URL (the first-open blank screen). + // The e2e server is shared across specs, so other companies exist and the + // bounce may pick any active one; the contract is only "not the archived + // company's routes anymore". + const awayFromArchived = (url: URL) => + url.pathname.endsWith("/dashboard") && !url.pathname.startsWith(`/${archived.prefix}/`); + + // Cold arrival #1: a fresh load straight onto the archived company's + // remembered URL (previously the first-open blank-screen crash) bounces + // to an active company. await page.goto(`/${archived.prefix}/dashboard`); - await page.waitForTimeout(2_500); + await page.waitForURL(awayFromArchived, { timeout: 15_000 }); + await expect(page.getByText("Archived Loop Archived is archived")).toBeVisible(); expect(fatal, `crash on direct load of archived company URL:\n${fatal.join("\n")}`).toEqual([]); - // Field repro #2: visit the active company, then return to the archived - // company's URL with client-side history navigation (the "switched back" - // crash). - await page.goto(`/${active.prefix}/dashboard`); - await page.waitForLoadState("networkidle"); - await page.goBack(); - await page.waitForTimeout(2_500); - expect(fatal, `crash on back-navigation to archived company URL:\n${fatal.join("\n")}`).toEqual([]); + // Cold arrival #2: re-arrival at the archived URL (previously the + // "switched back" crash) bounces the same way. + await page.goto(`/${archived.prefix}/issues`); + await page.waitForURL(awayFromArchived, { timeout: 15_000 }); + expect(fatal, `crash on re-arrival at archived company URL:\n${fatal.join("\n")}`).toEqual([]); + + // Deliberate visit: selecting the archived company from the companies list + // sticks — no bounce — so its pages (and the way back to unarchiving) stay + // reachable. + await page.goto(`/${active.prefix}/companies`); + await page.waitForLoadState("networkidle"); + await page.getByText("Archived Loop Archived").first().click(); + await page.waitForURL(new RegExp(`/${archived.prefix}/`), { timeout: 15_000 }); + await page.waitForTimeout(1_500); + expect(page.url()).toMatch(new RegExp(`/${archived.prefix}/`)); + expect(fatal, `crash during deliberate archived visit:\n${fatal.join("\n")}`).toEqual([]); - // The app must have landed somewhere real, not a blank unmounted root. const rootContent = await page.locator("#root").innerText().catch(() => ""); expect(rootContent.length).toBeGreaterThan(0); }); diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 34272d6e00..416bc7b659 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -217,6 +217,10 @@ vi.mock("../api/instanceSettings", () => ({ vi.mock("../lib/company-selection", () => ({ shouldSyncCompanySelectionFromRoute: () => false, + // No bounce in the shared harness: these tests exercise layout chrome, not + // archived-company routing (covered by company-selection unit tests and the + // archived-company-url e2e). + resolveArchivedCompanyBounce: () => null, })); vi.mock("../lib/main-content-focus", () => ({ diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a838fea1ec..e4f62b6e21 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -33,7 +33,8 @@ import { useAppsEnabled } from "../hooks/useAppsEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; -import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; +import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; +import { useOptionalToastActions } from "../context/ToastContext"; import { applyMainContentScrollTop, NavigationScrollMemory, @@ -94,6 +95,8 @@ export function Layout() { } = useSidebar(); const { openNewIssue, openOnboarding } = useDialogActions(); const { togglePanelVisible } = usePanel(); + // Optional: Layout also renders in harnesses without a ToastProvider. + const pushToast = useOptionalToastActions()?.pushToast ?? null; const { companies, loading: companiesLoading, @@ -240,6 +243,27 @@ export function Layout() { return; } + // Stale state (remembered paths, history, bookmarks, restored tabs) + // deposits users into archived companies long after archiving; a cold + // arrival bounces to an active company instead of dwelling there. + // Deliberate visits (the company is already the selection) stay put. + const bounce = resolveArchivedCompanyBounce({ + matchedCompany, + selectedCompanyId, + companies, + }); + if (bounce) { + pushToast?.({ + title: `${matchedCompany.name} is archived`, + body: `Switched to ${bounce.name}.`, + tone: "info", + dedupeKey: `archived-company-bounce:${matchedCompany.id}`, + }); + setSelectedCompanyId(bounce.id, { source: "route_sync" }); + navigate(`/${bounce.issuePrefix}/dashboard`, { replace: true }); + return; + } + if ( shouldSyncCompanySelectionFromRoute({ selectionSource, @@ -257,6 +281,7 @@ export function Layout() { location.pathname, location.search, navigate, + pushToast, selectionSource, selectedCompanyId, setSelectedCompanyId, diff --git a/ui/src/lib/company-selection.test.ts b/ui/src/lib/company-selection.test.ts index a8533a4b08..43f4deb8c5 100644 --- a/ui/src/lib/company-selection.test.ts +++ b/ui/src/lib/company-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldSyncCompanySelectionFromRoute } from "./company-selection"; +import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "./company-selection"; describe("shouldSyncCompanySelectionFromRoute", () => { it("does not resync when selection already matches the route", () => { @@ -32,3 +32,56 @@ describe("shouldSyncCompanySelectionFromRoute", () => { ).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(); + }); +}); diff --git a/ui/src/lib/company-selection.ts b/ui/src/lib/company-selection.ts index ce02cb4ddc..43236de27e 100644 --- a/ui/src/lib/company-selection.ts +++ b/ui/src/lib/company-selection.ts @@ -1,5 +1,43 @@ 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 function shouldSyncCompanySelectionFromRoute(params: { selectionSource: CompanySelectionSource; selectedCompanyId: string | null; diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index a1f42297c8..8e307201c4 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -29,6 +29,7 @@ import { CircleDot, DollarSign, Calendar, + ArchiveRestore, } from "lucide-react"; export function Companies() { @@ -74,6 +75,19 @@ export function Companies() { }, }); + // Unarchiving previously had no UI at all: archiving happens in company + // settings, but an archived company disappears from the sidebar switcher, + // so its settings page — and with it any way back — was only reachable by + // hand-typed URL. This list is the one place that still shows archived + // companies, so restoration lives here. + const unarchiveMutation = useMutation({ + mutationFn: (id: string) => companiesApi.update(id, { status: "active" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }); + }, + }); + useEffect(() => { setBreadcrumbs([{ label: "Companies" }]); }, [setBreadcrumbs]); @@ -224,6 +238,15 @@ export function Companies() { Rename + {company.status === "archived" && ( + unarchiveMutation.mutate(company.id)} + > + + Unarchive + + )}