Files
PaperClipAI/tests/e2e/archived-company-url.spec.ts
Devin Foley a09d7dcc06 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
2026-08-12 21:02:12 -07:00

85 lines
3.9 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
/**
* Archived-company URL handling.
*
* 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 }> {
const res = await page.request.post("/api/companies", { data: { name } });
expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true);
const company = await res.json();
return { id: company.id, prefix: company.issuePrefix ?? company.prefix ?? "E2E" };
}
function collectFatalErrors(page: Page): string[] {
const fatal: string[] = [];
page.on("pageerror", (err) => {
fatal.push(`PAGEERROR: ${err.message}`);
});
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)) {
return;
}
fatal.push(`CONSOLE: ${text.slice(0, 300)}`);
});
return fatal;
}
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");
const archived = await createCompany(page, "Archived Loop Archived");
const archiveRes = await page.request.patch(`/api/companies/${archived.id}`, {
data: { status: "archived" },
});
expect(archiveRes.ok(), `archive failed ${archiveRes.status()}: ${await archiveRes.text()}`).toBe(true);
// 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.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([]);
// 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([]);
const rootContent = await page.locator("#root").innerText().catch(() => "");
expect(rootContent.length).toBeGreaterThan(0);
});