mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## 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
85 lines
3.9 KiB
TypeScript
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);
|
|
});
|