Merge pull request #1217 from paperclipai/split/ui-onboarding-inbox-agent-details

Inbox, agent detail, and onboarding polish
This commit is contained in:
Dotta
2026-03-18 08:04:57 -05:00
committed by GitHub
12 changed files with 629 additions and 336 deletions

View File

@@ -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}`

View File

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

View File

@@ -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 <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
// Keep the first-run onboarding mounted until it completes.
if (onboardingOpen) {
return <NoCompaniesStartPage autoOpen={false} />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@@ -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 (
<div className="mx-auto max-w-xl py-10">

View File

@@ -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 (
<div className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
@@ -717,36 +706,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
</>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={
isCreate
? val!.bootstrapPrompt
: eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)
}
onChange={(v) =>
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;
}}
/>
</Field>
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
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.
</div>
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)}
onChange={(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 = `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent&apos;s prompt template or instructions file instead.
</div>
</>
)}
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
@@ -843,12 +828,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled}
/>
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div>
</div>
) : (
@@ -871,12 +850,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
/>
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div>
<CollapsibleSection
title="Advanced Run Policy"
@@ -964,69 +937,6 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
);
}
function formatSessionThreshold(value: number, suffix: string) {
if (value <= 0) return "Off";
return `${value.toLocaleString("en-US")} ${suffix}`;
}
function SessionCompactionPolicyCard({
adapterType,
resolution,
}: {
adapterType: string;
resolution: ResolvedSessionCompactionPolicy;
}) {
const { adapterSessionManagement, policy, source } = resolution;
if (!adapterSessionManagement) return null;
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
const nativeSummary =
adapterSessionManagement.nativeContextManagement === "confirmed"
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
: adapterSessionManagement.nativeContextManagement === "likely"
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
return (
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-sky-50">Session compaction</div>
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
{sourceLabel}
</span>
</div>
<p className="text-xs text-sky-100/90">
{nativeSummary}
</p>
<p className="text-xs text-sky-100/80">
{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."}
</p>
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
<div>
<div className="text-sky-100/60">Runs</div>
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
</div>
<div>
<div className="text-sky-100/60">Raw input</div>
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
</div>
<div>
<div className="text-sky-100/60">Age</div>
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
</div>
</div>
<p className="text-[11px] text-sky-100/75">
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."}
</p>
</div>
);
}
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);

View File

@@ -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() {
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
@@ -351,7 +357,12 @@ export function Layout() {
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies";
@@ -30,6 +30,7 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
@@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false);
const initialStep = onboardingOptions.initialStep ?? 1;
const existingCompanyId = onboardingOptions.companyId;
const routeOnboardingOptions =
companyPrefix && companiesLoading
? null
: resolveRouteOnboardingOptions({
pathname: location.pathname,
companyPrefix,
companies,
});
const effectiveOnboardingOpen =
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
const effectiveOnboardingOptions = onboardingOpen
? onboardingOptions
: routeOnboardingOptions ?? {};
const initialStep = effectiveOnboardingOptions.initialStep ?? 1;
const existingCompanyId = effectiveOnboardingOptions.companyId;
const [step, setStep] = useState<Step>(initialStep);
const [loading, setLoading] = useState(false);
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
useEffect(() => {
setRouteDismissed(false);
}, [location.pathname]);
// Sync step and company when onboarding opens with options.
// Keep this independent from company-list refreshes so Step 1 completion
// doesn't get reset after creating a company.
useEffect(() => {
if (!onboardingOpen) return;
const cId = onboardingOptions.companyId ?? null;
setStep(onboardingOptions.initialStep ?? 1);
if (!effectiveOnboardingOpen) return;
const cId = effectiveOnboardingOptions.companyId ?? null;
setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
}, [
onboardingOpen,
onboardingOptions.companyId,
onboardingOptions.initialStep
effectiveOnboardingOpen,
effectiveOnboardingOptions.companyId,
effectiveOnboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Resize textarea when step 3 is shown or description changes
useEffect(() => {
@@ -171,7 +193,7 @@ export function OnboardingWizard() {
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" ||
@@ -546,13 +568,16 @@ export function OnboardingWizard() {
}
}
if (!onboardingOpen) return null;
if (!effectiveOnboardingOpen) return null;
return (
<Dialog
open={onboardingOpen}
open={effectiveOnboardingOpen}
onOpenChange={(open) => {
if (!open) handleClose();
if (!open) {
setRouteDismissed(true);
handleClose();
}
}}
>
<DialogPortal>
@@ -762,6 +787,12 @@ export function OnboardingWizard() {
icon: Gem,
desc: "Local Gemini agent"
},
{
value: "process" as const,
label: "Process",
icon: Terminal,
desc: "Run a local command"
},
{
value: "opencode_local" as const,
label: "OpenCode",

View File

@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
computeInboxBadgeData,
getApprovalsForTab,
getInboxWorkItems,
getRecentTouchedIssues,
getUnreadTouchedIssues,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
saveLastInboxTab,
shouldShowInboxSection,
} from "./inbox";
const storage = new Map<string, string>();
@@ -46,6 +49,19 @@ function makeApproval(status: Approval["status"]): Approval {
};
}
function makeApprovalWithTimestamps(
id: string,
status: Approval["status"],
updatedAt: string,
): Approval {
return {
...makeApproval(status),
id,
createdAt: new Date(updatedAt),
updatedAt: new Date(updatedAt),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
@@ -231,6 +247,77 @@ describe("inbox helpers", () => {
expect(issues).toHaveLength(2);
});
it("shows recent approvals in updated order and unread approvals as actionable only", () => {
const approvals = [
makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
makeApprovalWithTimestamps(
"approval-revision",
"revision_requested",
"2026-03-11T03:00:00.000Z",
),
];
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "unread", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "all", "resolved").map((approval) => approval.id)).toEqual([
"approval-approved",
]);
});
it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-between",
"pending",
"2026-03-11T03:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [olderIssue, newerIssue],
approvals: [approval],
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`),
).toEqual([
"issue:1",
"approval:approval-between",
"issue:2",
]);
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({
tab: "recent",
hasItems: true,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "unread",
hasItems: true,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(false);
});
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);

View File

@@ -12,6 +12,18 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItem =
| {
kind: "issue";
timestamp: number;
issue: Issue;
}
| {
kind: "approval";
timestamp: number;
approval: Approval;
};
export interface InboxBadgeData {
inbox: number;
@@ -104,6 +116,85 @@ export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function getApprovalsForTab(
approvals: Approval[],
tab: InboxTab,
filter: InboxApprovalFilter,
): Approval[] {
const sortedApprovals = [...approvals].sort(
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
);
if (tab === "recent") return sortedApprovals;
if (tab === "unread") {
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
}
if (filter === "all") return sortedApprovals;
return sortedApprovals.filter((approval) => {
const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return filter === "actionable" ? isActionable : !isActionable;
});
}
export function approvalActivityTimestamp(approval: Approval): number {
const updatedAt = normalizeTimestamp(approval.updatedAt);
if (updatedAt > 0) return updatedAt;
return normalizeTimestamp(approval.createdAt);
}
export function getInboxWorkItems({
issues,
approvals,
}: {
issues: Issue[];
approvals: Approval[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
kind: "issue" as const,
timestamp: issueLastActivityTimestamp(issue),
issue,
})),
...approvals.map((approval) => ({
kind: "approval" as const,
timestamp: approvalActivityTimestamp(approval),
approval,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
if (a.kind === "approval" && b.kind === "approval") {
return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval);
}
return a.kind === "approval" ? -1 : 1;
});
}
export function shouldShowInboxSection({
tab,
hasItems,
showOnRecent,
showOnUnread,
showOnAll,
}: {
tab: InboxTab;
hasItems: boolean;
showOnRecent: boolean;
showOnUnread: boolean;
showOnAll: boolean;
}): boolean {
if (!hasItems) return false;
if (tab === "recent") return showOnRecent;
if (tab === "unread") return showOnUnread;
return showOnAll;
}
export function computeInboxBadgeData({
approvals,
joinRequests,

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
isOnboardingPath,
resolveRouteOnboardingOptions,
shouldRedirectCompanylessRouteToOnboarding,
} from "./onboarding-route";
describe("isOnboardingPath", () => {
it("matches the global onboarding route", () => {
expect(isOnboardingPath("/onboarding")).toBe(true);
});
it("matches a company-prefixed onboarding route", () => {
expect(isOnboardingPath("/pap/onboarding")).toBe(true);
});
it("ignores non-onboarding routes", () => {
expect(isOnboardingPath("/pap/dashboard")).toBe(false);
});
});
describe("resolveRouteOnboardingOptions", () => {
it("opens company creation for the global onboarding route", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/onboarding",
companies: [],
}),
).toEqual({ initialStep: 1 });
});
it("opens agent creation when the prefixed company exists", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/pap/onboarding",
companyPrefix: "pap",
companies: [{ id: "company-1", issuePrefix: "PAP" }],
}),
).toEqual({ initialStep: 2, companyId: "company-1" });
});
it("falls back to company creation when the prefixed company is missing", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/pap/onboarding",
companyPrefix: "pap",
companies: [],
}),
).toEqual({ initialStep: 1 });
});
});
describe("shouldRedirectCompanylessRouteToOnboarding", () => {
it("redirects companyless entry routes into onboarding", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/",
hasCompanies: false,
}),
).toBe(true);
});
it("does not redirect when already on onboarding", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/onboarding",
hasCompanies: false,
}),
).toBe(false);
});
it("does not redirect when companies exist", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/issues",
hasCompanies: true,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
type OnboardingRouteCompany = {
id: string;
issuePrefix: string;
};
export function isOnboardingPath(pathname: string): boolean {
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 1) {
return segments[0]?.toLowerCase() === "onboarding";
}
if (segments.length === 2) {
return segments[1]?.toLowerCase() === "onboarding";
}
return false;
}
export function resolveRouteOnboardingOptions(params: {
pathname: string;
companyPrefix?: string;
companies: OnboardingRouteCompany[];
}): { initialStep: 1 | 2; companyId?: string } | null {
const { pathname, companyPrefix, companies } = params;
if (!isOnboardingPath(pathname)) return null;
if (!companyPrefix) {
return { initialStep: 1 };
}
const matchedCompany =
companies.find(
(company) =>
company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(),
) ?? null;
if (!matchedCompany) {
return { initialStep: 1 };
}
return { initialStep: 2, companyId: matchedCompany.id };
}
export function shouldRedirectCompanylessRouteToOnboarding(params: {
pathname: string;
hasCompanies: boolean;
}): boolean {
return !params.hasCompanies && !isOnboardingPath(params.pathname);
}

View File

@@ -701,8 +701,8 @@ export function AgentDetail() {
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "skills") {
crumbs.push({ label: "Skills" });
// } else if (activeView === "skills") { // TODO: bring back later
// crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
@@ -862,7 +862,7 @@ export function AgentDetail() {
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "skills", label: "Skills" },
// { value: "skills", label: "Skills" }, // TODO: bring back later
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
@@ -955,11 +955,11 @@ export function AgentDetail() {
/>
)}
{activeView === "skills" && (
{/* {activeView === "skills" && (
<SkillsTab
agent={agent}
/>
)}
)} */}{/* TODO: bring back later */}
{activeView === "runs" && (
<RunsTab

View File

@@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard";
import { IssueRow } from "../components/IssueRow";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge";
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -40,13 +40,17 @@ import {
} from "lucide-react";
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar";
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab,
getInboxWorkItems,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
type InboxTab,
InboxApprovalFilter,
saveLastInboxTab,
shouldShowInboxSection,
type InboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
@@ -57,11 +61,9 @@ type InboxCategoryFilter =
| "approvals"
| "failed_runs"
| "alerts";
type InboxApprovalFilter = "all" | "actionable" | "resolved";
type SectionKey =
| "issues_i_touched"
| "work_items"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
@@ -82,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string {
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
}
function approvalStatusLabel(status: Approval["status"]): string {
return status.replaceAll("_", " ");
}
function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot;
if (!context) return null;
@@ -233,6 +239,95 @@ function FailedRunCard({
);
}
function ApprovalInboxRow({
approval,
requesterName,
onApprove,
onReject,
isPending,
}: {
approval: Approval;
requesterName: string | null;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type;
const showResolutionButtons =
approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className="flex items-start gap-2 sm:items-center">
<Link
to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
{label}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="capitalize">{approvalStatusLabel(approval.status)}</span>
{requesterName ? <span>requested by {requesterName}</span> : null}
<span>updated {timeAgo(approval.updatedAt)}</span>
</span>
</span>
</Link>
{showResolutionButtons ? (
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
{showResolutionButtons ? (
<div className="mt-3 flex gap-2 sm:hidden">
<Button
size="sm"
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 px-3"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
</div>
) : null}
</div>
);
}
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<string, string>();
@@ -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") && <Separator />}
{showSeparatorBefore("work_items") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
</h3>
<div className="grid gap-3">
{approvalsToRender.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={
approval.requestedByAgentId
? (agents ?? []).find((a) => 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}
/>
))}
<div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => {
if (item.kind === "approval") {
return (
<ApprovalInboxRow
key={`approval:${item.approval.id}`}
approval={item.approval}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => 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 (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
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)}`
}
/>
);
})}
</div>
</div>
</>
@@ -806,62 +938,6 @@ export function Inbox() {
</>
)}
{showTouchedSection && (
<>
{showSeparatorBefore("issues_i_touched") && <Separator />}
<div>
<div>
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
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)}`
}
/>
);
})}
</div>
</div>
</>
)}
</div>
);
}