feat(ui): bounce cold arrivals off archived company URLs, add Unarchive (#11302)

## 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
This commit is contained in:
Devin Foley 2026-08-12 21:02:12 -07:00 committed by GitHub
parent d24a79f741
commit a09d7dcc06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 185 additions and 25 deletions

View File

@ -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);
});

View File

@ -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", () => ({

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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() {
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
{company.status === "archived" && (
<DropdownMenuItem
disabled={unarchiveMutation.isPending}
onClick={() => unarchiveMutation.mutate(company.id)}
>
<ArchiveRestore className="h-3.5 w-3.5" />
Unarchive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"