From 868e210a95fd9aa8755730430377e8d431e0e313 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 13:51:46 -0700 Subject: [PATCH] feat(ui): post-import landing CTAs on every outcome branch (#12143) 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 > - Company import ends on one of two success screens: the full outcome, or a soft-success panel when the job's in-memory result expired before it could be read > - The soft-success panel named no company and offered no way in, and the full outcome never said that paused agents stay resumable after leaving the page > - Users on the soft-success path concluded the import vanished and ran it again, producing duplicate companies > - This pull request gives every success branch a named landing with a direct CTA into the new company and a pointer to the paused-agents banner > - The benefit is that a finished import always lands the user somewhere actionable ## Linked Issues or Issue Description **What existing behavior does this improve?** The outcome screens of the company import page. **Subsystem affected** Web UI — company import (`ui/src/pages/CompanyImport.tsx`). **Current behavior** The expired-job branch renders two sentences ("the company has been added — open it to view it") with no company name and no link. The full-outcome screen shows the activation checklist but does not say the checklist's resume actions remain available on the dashboard, so users treat the page as their only chance. **Proposed behavior** The expired branch keeps the landed company's name and dashboard path when readable, renders an "Open company dashboard" button, and notes that imported agents arrive paused and can be resumed from the dashboard banner. When the company is unreadable it gives explicit switcher guidance instead. The full-outcome screen states that paused items stay resumable from the dashboard. **Breaking changes** None. Pure UI copy/state additions to an existing page. ## What Changed - The `expired` import outcome now carries `companyName` and `dashboardPath`, captured from the already-fetched company in `onSuccess`. - The expired panel renders the company name, a dashboard CTA (`data-testid="import-expired-open-company"`), the paused-agents pointer, and a switcher fallback. - The full-outcome screen adds a line noting the dashboard offers the same resume actions as the activation checklist. ## Verification - `cd ui && npx vitest run src/pages/CompanyImport.test.tsx` — 24 tests pass; the soft-success test now asserts the name, pointer, and CTA, and a new test covers the unreadable-company fallback. - `cd ui && pnpm run typecheck` — clean. ## Risks - Low risk. The dashboard pointer references the paused-agents banner shipping in #12142; until that merges the sentence still points at the dashboard, where paused agents are already visible in the metric card. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and tool use, via Claude Code. ## 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 --- ui/src/pages/CompanyImport.test.tsx | 23 +++++++++++- ui/src/pages/CompanyImport.tsx | 56 +++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 3a70ea3a4d..272a924705 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -922,7 +922,14 @@ describe("CompanyImport", () => { // Success-leaning panel, not the failure panel. expect(container.textContent).toContain("Import completed"); - expect(container.textContent).toContain("open it to view it"); + // The readable company gives the panel a name and a direct CTA into the + // new company's dashboard, plus the paused-agents pointer. + expect(container.textContent).toContain("Imported Test"); + // The default import submits with pauseAutomations checked, so the + // paused pointer must show; it is gated off when the user unchecks it. + expect(container.textContent).toContain("Imported agents arrived paused"); + const openCompany = container.querySelector('[data-testid="import-expired-open-company"]'); + expect(openCompany).not.toBeNull(); expect(container.textContent).not.toContain("Import failed"); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" })); // The company list is refreshed so the new company appears in the switcher. @@ -934,6 +941,20 @@ describe("CompanyImport", () => { } }); + it("falls back to switcher guidance when the expired job's company is unreadable", async () => { + mockCompaniesApi.getImportJob.mockResolvedValue({ + job: { id: "job-1", status: "succeeded", result: { companyId: "company-2" } }, + }); + mockCompaniesApi.get.mockRejectedValue(new Error("forbidden")); + + await renderPageAndImport(); + + expect(container.textContent).toContain("Import completed"); + expect(container.textContent).toContain("select it from the company switcher"); + // No readable company, so no dashboard CTA — the switcher guidance stands in. + expect(container.querySelector('[data-testid="import-expired-open-company"]')).toBeNull(); + }); + it("surfaces a first-poll 404 as an error because the job never existed", async () => { // A 404 on the very first poll — before the client ever saw the job // running — means the id never existed. That stays a hard error. diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 6104c9244a..4e86104af0 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -899,7 +899,12 @@ export function CompanyImport() { dashboardPath: string; pausedAutomations: boolean; } - | { kind: "expired" } + | { + kind: "expired"; + companyName: string | null; + dashboardPath: string | null; + pausedAutomations: boolean; + } | null >(null); const [activationChecked, setActivationChecked] = useState>(new Set()); @@ -1243,18 +1248,28 @@ export function CompanyImport() { if (outcome.status === "completed-expired") { // The import finished and wrote all its data, but the job's result // expired (or was never retained) before we could read it. This is a - // success, not a failure: surface it gently and let the refreshed - // switcher carry the user into the new company. + // success, not a failure: keep the landed company's identity so the + // outcome screen can take the user straight there instead of leaving + // them to hunt through the switcher. + let expiredCompanyName: string | null = null; + let expiredDashboardPath: string | null = null; if (outcome.companyId) { try { const importedCompany = await companiesApi.get(outcome.companyId); setSelectedCompanyId(importedCompany.id); + expiredCompanyName = importedCompany.name; + expiredDashboardPath = `/${importedCompany.issuePrefix}/dashboard`; } catch { // The company id may be unreadable (permissions, race); the // refreshed company list still surfaces the import. } } - setImportOutcome({ kind: "expired" }); + setImportOutcome({ + kind: "expired", + companyName: expiredCompanyName, + dashboardPath: expiredDashboardPath, + pausedAutomations: submittedPauseAutomations, + }); pushToast({ tone: "success", title: "Import completed", @@ -1619,16 +1634,37 @@ export function CompanyImport() { // Soft success: the import finished and wrote all its data, but the job's // in-memory result expired before we could read it. Never a failure — the // company list has been refreshed, so the imported company is available - // from the switcher. + // from the switcher, and when we could read the company we take the user + // straight to it. return (

Import completed

- The import finished and your company is ready. Its detailed summary is no - longer available, but the company has been added — open it to view it. + {importOutcome.companyName + ? <>The import finished and {importOutcome.companyName} is ready. Its detailed summary is no longer available. + : "The import finished and your company is ready. Its detailed summary is no longer available, but the company has been added — select it from the company switcher to view it."}

+ {importOutcome.pausedAutomations ? ( +

+ Imported agents arrived paused — resume them from the company's Agents page so assigned tasks can start. +

+ ) : null}
+ {importOutcome.dashboardPath ? ( +
+ +
+ ) : null}
); } @@ -1730,6 +1766,12 @@ export function CompanyImport() { )} + {importOutcome.pausedAutomations ? ( +

+ Anything left paused here stays visible on the company's Agents and Routines pages, which offer the same resume actions — nothing is lost if you leave this page. +

+ ) : null} + {/* Force a fresh dashboard load so newly imported agents are immediately visible. */}