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}
+
)}
Documentation
{health?.version && (
- v{health.version}
+
+
+ v
+
+ v{health.version}
+
)}
();
+ 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(initialStep);
const [loading, setLoading] = useState(false);
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
const [createdAgentId, setCreatedAgentId] = useState(null);
const [createdIssueRef, setCreatedIssueRef] = useState(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 (
{
- if (!open) handleClose();
+ if (!open) {
+ setRouteDismissed(true);
+ handleClose();
+ }
}}
>
@@ -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",
diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts
index 9b7edb06..4821b6cf 100644
--- a/ui/src/lib/inbox.test.ts
+++ b/ui/src/lib/inbox.test.ts
@@ -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();
@@ -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);
diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts
index 88447a98..b9a74f72 100644
--- a/ui/src/lib/inbox.ts
+++ b/ui/src/lib/inbox.ts
@@ -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,
diff --git a/ui/src/lib/onboarding-route.test.ts b/ui/src/lib/onboarding-route.test.ts
new file mode 100644
index 00000000..2d2b25c2
--- /dev/null
+++ b/ui/src/lib/onboarding-route.test.ts
@@ -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);
+ });
+});
diff --git a/ui/src/lib/onboarding-route.ts b/ui/src/lib/onboarding-route.ts
new file mode 100644
index 00000000..425d1fb6
--- /dev/null
+++ b/ui/src/lib/onboarding-route.ts
@@ -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);
+}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index e9c33364..f11be75e 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -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" && (
- )}
+ )} */}{/* TODO: bring back later */}
{activeView === "runs" && (
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 (
+
+
+
+
+
+
+
+
+
+
+ {label}
+
+
+ {approvalStatusLabel(approval.status)}
+ {requesterName ? requested by {requesterName} : null}
+ updated {timeAgo(approval.updatedAt)}
+
+
+
+ {showResolutionButtons ? (
+
+
+ Approve
+
+
+ Reject
+
+
+ ) : null}
+
+ {showResolutionButtons ? (
+
+
+ Approve
+
+
+ Reject
+
+
+ ) : 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)}`
- }
- />
- );
- })}
-
-
- >
- )}
);
}