diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index f1dbd0f5..f5f32be8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task"; test.describe("Onboarding wizard", () => { test("completes full wizard flow", async ({ page }) => { - // Navigate to root — should auto-open onboarding when no companies exist await page.goto("/"); - // If the wizard didn't auto-open (company already exists), click the button const wizardHeading = page.locator("h3", { hasText: "Name your company" }); const newCompanyBtn = page.getByRole("button", { name: "New Company" }); - // Wait for either the wizard or the start page await expect( wizardHeading.or(newCompanyBtn) ).toBeVisible({ timeout: 15_000 }); @@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => { await newCompanyBtn.click(); } - // ----------------------------------------------------------- - // Step 1: Name your company - // ----------------------------------------------------------- await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); - await expect(page.locator("text=Step 1 of 4")).toBeVisible(); const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); await companyNameInput.fill(COMPANY_NAME); - // Click Next const nextButton = page.getByRole("button", { name: "Next" }); await nextButton.click(); - // ----------------------------------------------------------- - // Step 2: Create your first agent - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Create your first agent" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 2 of 4")).toBeVisible(); - // Agent name should default to "CEO" const agentNameInput = page.locator('input[placeholder="CEO"]'); await expect(agentNameInput).toHaveValue(AGENT_NAME); - // Claude Code adapter should be selected by default await expect( page.locator("button", { hasText: "Claude Code" }).locator("..") ).toBeVisible(); - // Select the "Process" adapter to avoid needing a real CLI tool installed - await page.locator("button", { hasText: "Process" }).click(); + await page.getByRole("button", { name: "More Agent Adapter Types" }).click(); + await page.getByRole("button", { name: "Process" }).click(); - // Fill in process adapter fields const commandInput = page.locator('input[placeholder="e.g. node, python"]'); await commandInput.fill("echo"); const argsInput = page.locator( @@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => { ); await argsInput.fill("hello"); - // Click Next (process adapter skips environment test) await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 3: Give it something to do - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Give it something to do" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 3 of 4")).toBeVisible(); - // Clear default title and set our test title const taskTitleInput = page.locator( 'input[placeholder="e.g. Research competitor pricing"]' ); await taskTitleInput.clear(); await taskTitleInput.fill(TASK_TITLE); - // Click Next await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 4: Ready to launch - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Ready to launch" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 4 of 4")).toBeVisible(); - // Verify summary displays our created entities await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); - // Click "Open Issue" - await page.getByRole("button", { name: "Open Issue" }).click(); + await page.getByRole("button", { name: "Create & Open Issue" }).click(); - // Should navigate to the issue page await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); - // ----------------------------------------------------------- - // Verify via API that entities were created - // ----------------------------------------------------------- const baseUrl = page.url().split("/").slice(0, 3).join("/"); - // List companies and find ours const companiesRes = await page.request.get(`${baseUrl}/api/companies`); expect(companiesRes.ok()).toBe(true); const companies = await companiesRes.json(); @@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => { ); expect(company).toBeTruthy(); - // List agents for our company const agentsRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/agents` ); @@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => { expect(ceoAgent.role).toBe("ceo"); expect(ceoAgent.adapterType).toBe("process"); - // List issues for our company const issuesRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/issues` ); @@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => { expect(task.assigneeAgentId).toBe(ceoAgent.id); if (!SKIP_LLM) { - // LLM-dependent: wait for the heartbeat to transition the issue await expect(async () => { const res = await page.request.get( `${baseUrl}/api/issues/${task.id}` diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 5ae1b677..fd7e8b59 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ // The webServer directive starts `paperclipai run` before tests. // Expects `pnpm paperclipai` to be runnable from repo root. webServer: { - command: `pnpm paperclipai run --yes`, + command: `pnpm paperclipai run`, url: `${BASE_URL}/api/health`, reuseExistingServer: !!process.env.CI, timeout: 120_000, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ff1daecf..05aa5381 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from "react"; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; +import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -175,24 +175,13 @@ function LegacySettingsRedirect() { } function OnboardingRoutePage() { - const { companies, loading } = useCompany(); - const { onboardingOpen, openOnboarding } = useDialog(); + const { companies } = useCompany(); + const { openOnboarding } = useDialog(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); - const opened = useRef(false); const matchedCompany = companyPrefix ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null : null; - useEffect(() => { - if (loading || opened.current || onboardingOpen) return; - opened.current = true; - if (matchedCompany) { - openOnboarding({ initialStep: 2, companyId: matchedCompany.id }); - return; - } - openOnboarding(); - }, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]); - const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 @@ -227,19 +216,22 @@ function OnboardingRoutePage() { function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); - const { onboardingOpen } = useDialog(); + const location = useLocation(); if (loading) { return
Loading...
; } - // Keep the first-run onboarding mounted until it completes. - if (onboardingOpen) { - return ; - } - const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() { const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() { ); } -function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) { +function NoCompaniesStartPage() { const { openOnboarding } = useDialog(); - const opened = useRef(false); - - useEffect(() => { - if (!autoOpen) return; - if (opened.current) return; - opened.current = true; - openOnboarding(); - }, [autoOpen, openOnboarding]); return (
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c6e48cd0..3b3d53c0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,11 +1,6 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; -import { - hasSessionCompactionThresholds, - resolveSessionCompactionPolicy, - type ResolvedSessionCompactionPolicy, -} from "@paperclipai/adapter-utils"; import type { Agent, AdapterEnvironmentTestResult, @@ -408,12 +403,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { heartbeat: mergedHeartbeat, }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); - const sessionCompaction = useMemo( - () => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig), - [adapterType, effectiveRuntimeConfig], - ); - const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement); - return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} @@ -717,36 +706,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} )} - - - isCreate - ? set!({ bootstrapPrompt: v }) - : mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) - } - placeholder="Optional initial setup prompt for the first run" - contentClassName="min-h-[44px] text-sm font-mono" - imageUploadHandler={async (file) => { - const namespace = isCreate - ? "agents/drafts/bootstrap-prompt" - : `agents/${props.agent.id}/bootstrap-prompt`; - const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); - return asset.contentPath; - }} - /> - -
- Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it. -
+ {!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( + <> + + + mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) + } + placeholder="Optional initial setup prompt for the first run" + contentClassName="min-h-[44px] text-sm font-mono" + imageUploadHandler={async (file) => { + const namespace = `agents/${props.agent.id}/bootstrap-prompt`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> + +
+ Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead. +
+ + )} {adapterType === "claude_local" && ( )} @@ -843,12 +828,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> - {showSessionCompactionCard && ( - - )}
) : ( @@ -871,12 +850,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> - {showSessionCompactionCard && ( - - )} -
-
Session compaction
- - {sourceLabel} - -
-

- {nativeSummary} -

-

- {rotationDisabled - ? "No Paperclip-managed fresh-session thresholds are active for this adapter." - : "Paperclip will start a fresh session when one of these thresholds is reached."} -

-
-
-
Runs
-
{formatSessionThreshold(policy.maxSessionRuns, "runs")}
-
-
-
Raw input
-
{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}
-
-
-
Age
-
{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}
-
-
-

- A large cumulative raw token total does not mean the full session is resent on every heartbeat. - {source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."} -

- - ); -} - /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8d2e3e6f..8bae6920 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -32,6 +32,7 @@ import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; @@ -298,7 +299,12 @@ export function Layout() { Documentation {health?.version && ( - v{health.version} + + + v + + v{health.version} + )} + + + ) : null} + + {showResolutionButtons ? ( +
+ + +
+ ) : null} + + ); +} + export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -334,6 +429,10 @@ export function Inbox() { () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], ); + const issuesToRender = useMemo( + () => (tab === "unread" ? unreadTouchedIssues : touchedIssues), + [tab, touchedIssues, unreadTouchedIssues], + ); const agentById = useMemo(() => { const map = new Map(); @@ -361,28 +460,28 @@ export function Inbox() { return ids; }, [heartbeatRuns]); - const allApprovals = useMemo( + const approvalsToRender = useMemo( + () => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter), + [approvals, tab, allApprovalFilter], + ); + const showJoinRequestsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; + const showTouchedCategory = + allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched"; + const showApprovalsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "approvals"; + const showFailedRunsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; + const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; + const workItemsToRender = useMemo( () => - [...(approvals ?? [])].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ), - [approvals], + getInboxWorkItems({ + issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, + approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, + }), + [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab], ); - const actionableApprovals = useMemo( - () => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)), - [allApprovals], - ); - - const filteredAllApprovals = useMemo(() => { - if (allApprovalFilter === "all") return allApprovals; - - return allApprovals.filter((approval) => { - const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status); - return allApprovalFilter === "actionable" ? isActionable : !isActionable; - }); - }, [allApprovals, allApprovalFilter]); - const agentName = (id: string | null) => { if (!id) return null; return agentById.get(id) ?? null; @@ -505,39 +604,29 @@ export function Inbox() { !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = touchedIssues.length > 0; - - const showJoinRequestsCategory = - allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; - const showTouchedCategory = - allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched"; - const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals"; - const showFailedRunsCategory = - allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; - const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - - const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals; - const showTouchedSection = - tab === "all" - ? showTouchedCategory && hasTouchedIssues - : tab === "unread" - ? unreadTouchedIssues.length > 0 - : hasTouchedIssues; + const showWorkItemsSection = workItemsToRender.length > 0; const showJoinRequestsSection = tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; - const showApprovalsSection = tab === "all" - ? showApprovalsCategory && filteredAllApprovals.length > 0 - : actionableApprovals.length > 0; - const showFailedRunsSection = - tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures; - const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts; + const showFailedRunsSection = shouldShowInboxSection({ + tab, + hasItems: hasRunFailures, + showOnRecent: hasRunFailures, + showOnUnread: hasRunFailures, + showOnAll: showFailedRunsCategory && hasRunFailures, + }); + const showAlertsSection = shouldShowInboxSection({ + tab, + hasItems: hasAlerts, + showOnRecent: hasAlerts, + showOnUnread: hasAlerts, + showOnAll: showAlertsCategory && hasAlerts, + }); const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, - showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, - showTouchedSection ? "issues_i_touched" : null, + showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); const allLoaded = @@ -643,29 +732,72 @@ export function Inbox() { /> )} - {showApprovalsSection && ( + {showWorkItemsSection && ( <> - {showSeparatorBefore("approvals") && } + {showSeparatorBefore("work_items") && }
-

- {tab === "unread" ? "Approvals Needing Action" : "Approvals"} -

-
- {approvalsToRender.map((approval) => ( - a.id === approval.requestedByAgentId) ?? null - : null - } - onApprove={() => approveMutation.mutate(approval.id)} - onReject={() => rejectMutation.mutate(approval.id)} - detailLink={`/approvals/${approval.id}`} - isPending={approveMutation.isPending || rejectMutation.isPending} - /> - ))} +
+ {workItemsToRender.map((item) => { + if (item.kind === "approval") { + return ( + approveMutation.mutate(item.approval.id)} + onReject={() => rejectMutation.mutate(item.approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> + ); + } + + const issue = item.issue; + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + return ( + + + + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={ + issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}` + } + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + trailingMeta={ + issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}` + } + /> + ); + })}
@@ -806,62 +938,6 @@ export function Inbox() { )} - {showTouchedSection && ( - <> - {showSeparatorBefore("issues_i_touched") && } -
-
- {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { - const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); - const isFading = fadingOutIssues.has(issue.id); - return ( - - - - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={ - issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}` - } - unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} - onMarkRead={() => markReadMutation.mutate(issue.id)} - trailingMeta={ - issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}` - } - /> - ); - })} -
-
- - )}
); }