feat(ui): post-import landing CTAs on every outcome branch (#12143)

## 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
This commit is contained in:
Devin Foley 2026-08-25 13:51:46 -07:00 committed by GitHub
parent 11f6c754c9
commit 868e210a95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 71 additions and 8 deletions

View File

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

View File

@ -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<Set<string>>(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 (
<div className="max-w-6xl space-y-4 px-5 py-5">
<div>
<h2 className="text-base font-semibold">Import completed</h2>
<p className="text-xs text-muted-foreground mt-1">
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 <span className="font-medium text-foreground">{importOutcome.companyName}</span> 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."}
</p>
{importOutcome.pausedAutomations ? (
<p className="text-xs text-muted-foreground mt-1">
Imported agents arrived paused resume them from the company's Agents page so assigned tasks can start.
</p>
) : null}
</div>
{importOutcome.dashboardPath ? (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
data-testid="import-expired-open-company"
// Force a fresh dashboard load so newly imported agents are
// immediately visible (same reason as the full-outcome CTA).
onClick={() => window.location.assign(importOutcome.dashboardPath!)}
>
Open company dashboard
</Button>
</div>
) : null}
</div>
);
}
@ -1730,6 +1766,12 @@ export function CompanyImport() {
</div>
)}
{importOutcome.pausedAutomations ? (
<p className="text-xs text-muted-foreground">
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.
</p>
) : null}
{/* Force a fresh dashboard load so newly imported agents are immediately visible. */}
<div className="flex flex-wrap items-center gap-2">
<Button