Merge pull request #1217 from paperclipai/split/ui-onboarding-inbox-agent-details
Inbox, agent detail, and onboarding polish
This commit is contained in:
@@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task";
|
|||||||
|
|
||||||
test.describe("Onboarding wizard", () => {
|
test.describe("Onboarding wizard", () => {
|
||||||
test("completes full wizard flow", async ({ page }) => {
|
test("completes full wizard flow", async ({ page }) => {
|
||||||
// Navigate to root — should auto-open onboarding when no companies exist
|
|
||||||
await page.goto("/");
|
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 wizardHeading = page.locator("h3", { hasText: "Name your company" });
|
||||||
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
|
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
|
||||||
|
|
||||||
// Wait for either the wizard or the start page
|
|
||||||
await expect(
|
await expect(
|
||||||
wizardHeading.or(newCompanyBtn)
|
wizardHeading.or(newCompanyBtn)
|
||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => {
|
|||||||
await newCompanyBtn.click();
|
await newCompanyBtn.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 1: Name your company
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
|
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"]');
|
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
|
||||||
await companyNameInput.fill(COMPANY_NAME);
|
await companyNameInput.fill(COMPANY_NAME);
|
||||||
|
|
||||||
// Click Next
|
|
||||||
const nextButton = page.getByRole("button", { name: "Next" });
|
const nextButton = page.getByRole("button", { name: "Next" });
|
||||||
await nextButton.click();
|
await nextButton.click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 2: Create your first agent
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Create your first agent" })
|
page.locator("h3", { hasText: "Create your first agent" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).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"]');
|
const agentNameInput = page.locator('input[placeholder="CEO"]');
|
||||||
await expect(agentNameInput).toHaveValue(AGENT_NAME);
|
await expect(agentNameInput).toHaveValue(AGENT_NAME);
|
||||||
|
|
||||||
// Claude Code adapter should be selected by default
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("button", { hasText: "Claude Code" }).locator("..")
|
page.locator("button", { hasText: "Claude Code" }).locator("..")
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Select the "Process" adapter to avoid needing a real CLI tool installed
|
await page.getByRole("button", { name: "More Agent Adapter Types" }).click();
|
||||||
await page.locator("button", { hasText: "Process" }).click();
|
await page.getByRole("button", { name: "Process" }).click();
|
||||||
|
|
||||||
// Fill in process adapter fields
|
|
||||||
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
|
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
|
||||||
await commandInput.fill("echo");
|
await commandInput.fill("echo");
|
||||||
const argsInput = page.locator(
|
const argsInput = page.locator(
|
||||||
@@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => {
|
|||||||
);
|
);
|
||||||
await argsInput.fill("hello");
|
await argsInput.fill("hello");
|
||||||
|
|
||||||
// Click Next (process adapter skips environment test)
|
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 3: Give it something to do
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Give it something to do" })
|
page.locator("h3", { hasText: "Give it something to do" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).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(
|
const taskTitleInput = page.locator(
|
||||||
'input[placeholder="e.g. Research competitor pricing"]'
|
'input[placeholder="e.g. Research competitor pricing"]'
|
||||||
);
|
);
|
||||||
await taskTitleInput.clear();
|
await taskTitleInput.clear();
|
||||||
await taskTitleInput.fill(TASK_TITLE);
|
await taskTitleInput.fill(TASK_TITLE);
|
||||||
|
|
||||||
// Click Next
|
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 4: Ready to launch
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Ready to launch" })
|
page.locator("h3", { hasText: "Ready to launch" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).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=" + COMPANY_NAME)).toBeVisible();
|
||||||
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
|
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
|
||||||
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
|
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
|
||||||
|
|
||||||
// Click "Open Issue"
|
await page.getByRole("button", { name: "Create & Open Issue" }).click();
|
||||||
await page.getByRole("button", { name: "Open Issue" }).click();
|
|
||||||
|
|
||||||
// Should navigate to the issue page
|
|
||||||
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
|
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Verify via API that entities were created
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
||||||
|
|
||||||
// List companies and find ours
|
|
||||||
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
||||||
expect(companiesRes.ok()).toBe(true);
|
expect(companiesRes.ok()).toBe(true);
|
||||||
const companies = await companiesRes.json();
|
const companies = await companiesRes.json();
|
||||||
@@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
);
|
);
|
||||||
expect(company).toBeTruthy();
|
expect(company).toBeTruthy();
|
||||||
|
|
||||||
// List agents for our company
|
|
||||||
const agentsRes = await page.request.get(
|
const agentsRes = await page.request.get(
|
||||||
`${baseUrl}/api/companies/${company.id}/agents`
|
`${baseUrl}/api/companies/${company.id}/agents`
|
||||||
);
|
);
|
||||||
@@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
expect(ceoAgent.role).toBe("ceo");
|
expect(ceoAgent.role).toBe("ceo");
|
||||||
expect(ceoAgent.adapterType).toBe("process");
|
expect(ceoAgent.adapterType).toBe("process");
|
||||||
|
|
||||||
// List issues for our company
|
|
||||||
const issuesRes = await page.request.get(
|
const issuesRes = await page.request.get(
|
||||||
`${baseUrl}/api/companies/${company.id}/issues`
|
`${baseUrl}/api/companies/${company.id}/issues`
|
||||||
);
|
);
|
||||||
@@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
expect(task.assigneeAgentId).toBe(ceoAgent.id);
|
expect(task.assigneeAgentId).toBe(ceoAgent.id);
|
||||||
|
|
||||||
if (!SKIP_LLM) {
|
if (!SKIP_LLM) {
|
||||||
// LLM-dependent: wait for the heartbeat to transition the issue
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const res = await page.request.get(
|
const res = await page.request.get(
|
||||||
`${baseUrl}/api/issues/${task.id}`
|
`${baseUrl}/api/issues/${task.id}`
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
// The webServer directive starts `paperclipai run` before tests.
|
// The webServer directive starts `paperclipai run` before tests.
|
||||||
// Expects `pnpm paperclipai` to be runnable from repo root.
|
// Expects `pnpm paperclipai` to be runnable from repo root.
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `pnpm paperclipai run --yes`,
|
command: `pnpm paperclipai run`,
|
||||||
url: `${BASE_URL}/api/health`,
|
url: `${BASE_URL}/api/health`,
|
||||||
reuseExistingServer: !!process.env.CI,
|
reuseExistingServer: !!process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys";
|
|||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
import { loadLastInboxTab } from "./lib/inbox";
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
|
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||||
|
|
||||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -175,24 +175,13 @@ function LegacySettingsRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OnboardingRoutePage() {
|
function OnboardingRoutePage() {
|
||||||
const { companies, loading } = useCompany();
|
const { companies } = useCompany();
|
||||||
const { onboardingOpen, openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
const opened = useRef(false);
|
|
||||||
const matchedCompany = companyPrefix
|
const matchedCompany = companyPrefix
|
||||||
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
||||||
: 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
|
const title = matchedCompany
|
||||||
? `Add another agent to ${matchedCompany.name}`
|
? `Add another agent to ${matchedCompany.name}`
|
||||||
: companies.length > 0
|
: companies.length > 0
|
||||||
@@ -227,19 +216,22 @@ function OnboardingRoutePage() {
|
|||||||
|
|
||||||
function CompanyRootRedirect() {
|
function CompanyRootRedirect() {
|
||||||
const { companies, selectedCompany, loading } = useCompany();
|
const { companies, selectedCompany, loading } = useCompany();
|
||||||
const { onboardingOpen } = useDialog();
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
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;
|
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||||
if (!targetCompany) {
|
if (!targetCompany) {
|
||||||
|
if (
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: location.pathname,
|
||||||
|
hasCompanies: false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
return <NoCompaniesStartPage />;
|
return <NoCompaniesStartPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
|
|||||||
|
|
||||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||||
if (!targetCompany) {
|
if (!targetCompany) {
|
||||||
|
if (
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: location.pathname,
|
||||||
|
hasCompanies: false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
return <NoCompaniesStartPage />;
|
return <NoCompaniesStartPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
|
function NoCompaniesStartPage() {
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
const opened = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoOpen) return;
|
|
||||||
if (opened.current) return;
|
|
||||||
opened.current = true;
|
|
||||||
openOnboarding();
|
|
||||||
}, [autoOpen, openOnboarding]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||||
import {
|
|
||||||
hasSessionCompactionThresholds,
|
|
||||||
resolveSessionCompactionPolicy,
|
|
||||||
type ResolvedSessionCompactionPolicy,
|
|
||||||
} from "@paperclipai/adapter-utils";
|
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
@@ -408,12 +403,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
heartbeat: mergedHeartbeat,
|
heartbeat: mergedHeartbeat,
|
||||||
};
|
};
|
||||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||||
const sessionCompaction = useMemo(
|
|
||||||
() => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig),
|
|
||||||
[adapterType, effectiveRuntimeConfig],
|
|
||||||
);
|
|
||||||
const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", cards && "space-y-6")}>
|
<div className={cn("relative", cards && "space-y-6")}>
|
||||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
{/* ---- 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}>
|
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
|
||||||
<MarkdownEditor
|
<>
|
||||||
value={
|
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
|
||||||
isCreate
|
<MarkdownEditor
|
||||||
? val!.bootstrapPrompt
|
value={eff(
|
||||||
: eff(
|
"adapterConfig",
|
||||||
"adapterConfig",
|
"bootstrapPromptTemplate",
|
||||||
"bootstrapPromptTemplate",
|
String(config.bootstrapPromptTemplate ?? ""),
|
||||||
String(config.bootstrapPromptTemplate ?? ""),
|
)}
|
||||||
)
|
onChange={(v) =>
|
||||||
}
|
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||||
onChange={(v) =>
|
}
|
||||||
isCreate
|
placeholder="Optional initial setup prompt for the first run"
|
||||||
? set!({ bootstrapPrompt: v })
|
contentClassName="min-h-[44px] text-sm font-mono"
|
||||||
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
imageUploadHandler={async (file) => {
|
||||||
}
|
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
|
||||||
placeholder="Optional initial setup prompt for the first run"
|
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||||
contentClassName="min-h-[44px] text-sm font-mono"
|
return asset.contentPath;
|
||||||
imageUploadHandler={async (file) => {
|
}}
|
||||||
const namespace = isCreate
|
/>
|
||||||
? "agents/drafts/bootstrap-prompt"
|
</Field>
|
||||||
: `agents/${props.agent.id}/bootstrap-prompt`;
|
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
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.
|
||||||
return asset.contentPath;
|
</div>
|
||||||
}}
|
</>
|
||||||
/>
|
)}
|
||||||
</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>
|
|
||||||
{adapterType === "claude_local" && (
|
{adapterType === "claude_local" && (
|
||||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||||
)}
|
)}
|
||||||
@@ -843,12 +828,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
numberHint={help.intervalSec}
|
numberHint={help.intervalSec}
|
||||||
showNumber={val!.heartbeatEnabled}
|
showNumber={val!.heartbeatEnabled}
|
||||||
/>
|
/>
|
||||||
{showSessionCompactionCard && (
|
|
||||||
<SessionCompactionPolicyCard
|
|
||||||
adapterType={adapterType}
|
|
||||||
resolution={sessionCompaction}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -871,12 +850,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
numberHint={help.intervalSec}
|
numberHint={help.intervalSec}
|
||||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||||
/>
|
/>
|
||||||
{showSessionCompactionCard && (
|
|
||||||
<SessionCompactionPolicyCard
|
|
||||||
adapterType={adapterType}
|
|
||||||
resolution={sessionCompaction}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Advanced Run Policy"
|
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 ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||||
|
|
||||||
@@ -298,7 +299,12 @@ export function Layout() {
|
|||||||
<span className="truncate">Documentation</span>
|
<span className="truncate">Documentation</span>
|
||||||
</a>
|
</a>
|
||||||
{health?.version && (
|
{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>
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
<Link
|
<Link
|
||||||
@@ -351,7 +357,12 @@ export function Layout() {
|
|||||||
<span className="truncate">Documentation</span>
|
<span className="truncate">Documentation</span>
|
||||||
</a>
|
</a>
|
||||||
{health?.version && (
|
{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>
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||||
|
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
|
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
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() {
|
export function OnboardingWizard() {
|
||||||
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
||||||
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
|
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
|
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||||
|
|
||||||
const initialStep = onboardingOptions.initialStep ?? 1;
|
const routeOnboardingOptions =
|
||||||
const existingCompanyId = onboardingOptions.companyId;
|
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 [step, setStep] = useState<Step>(initialStep);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
|
|||||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||||
const [createdIssueRef, setCreatedIssueRef] = 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.
|
// Sync step and company when onboarding opens with options.
|
||||||
// Keep this independent from company-list refreshes so Step 1 completion
|
// Keep this independent from company-list refreshes so Step 1 completion
|
||||||
// doesn't get reset after creating a company.
|
// doesn't get reset after creating a company.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onboardingOpen) return;
|
if (!effectiveOnboardingOpen) return;
|
||||||
const cId = onboardingOptions.companyId ?? null;
|
const cId = effectiveOnboardingOptions.companyId ?? null;
|
||||||
setStep(onboardingOptions.initialStep ?? 1);
|
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||||
setCreatedCompanyId(cId);
|
setCreatedCompanyId(cId);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
}, [
|
}, [
|
||||||
onboardingOpen,
|
effectiveOnboardingOpen,
|
||||||
onboardingOptions.companyId,
|
effectiveOnboardingOptions.companyId,
|
||||||
onboardingOptions.initialStep
|
effectiveOnboardingOptions.initialStep
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Backfill issue prefix for an existing company once companies are loaded.
|
// Backfill issue prefix for an existing company once companies are loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
||||||
const company = companies.find((c) => c.id === createdCompanyId);
|
const company = companies.find((c) => c.id === createdCompanyId);
|
||||||
if (company) setCreatedCompanyPrefix(company.issuePrefix);
|
if (company) setCreatedCompanyPrefix(company.issuePrefix);
|
||||||
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
||||||
|
|
||||||
// Resize textarea when step 3 is shown or description changes
|
// Resize textarea when step 3 is shown or description changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,7 +193,7 @@ export function OnboardingWizard() {
|
|||||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||||
: ["agents", "none", "adapter-models", adapterType],
|
: ["agents", "none", "adapter-models", adapterType],
|
||||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||||
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||||
});
|
});
|
||||||
const isLocalAdapter =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
@@ -546,13 +568,16 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!onboardingOpen) return null;
|
if (!effectiveOnboardingOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={onboardingOpen}
|
open={effectiveOnboardingOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) handleClose();
|
if (!open) {
|
||||||
|
setRouteDismissed(true);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -762,6 +787,12 @@ export function OnboardingWizard() {
|
|||||||
icon: Gem,
|
icon: Gem,
|
||||||
desc: "Local Gemini agent"
|
desc: "Local Gemini agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "process" as const,
|
||||||
|
label: "Process",
|
||||||
|
icon: Terminal,
|
||||||
|
desc: "Run a local command"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "opencode_local" as const,
|
value: "opencode_local" as const,
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
getApprovalsForTab,
|
||||||
|
getInboxWorkItems,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
|
shouldShowInboxSection,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
|
|
||||||
const storage = new Map<string, string>();
|
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 {
|
function makeJoinRequest(id: string): JoinRequest {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -231,6 +247,77 @@ describe("inbox helpers", () => {
|
|||||||
expect(issues).toHaveLength(2);
|
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", () => {
|
it("limits recent touched issues before unread badge counting", () => {
|
||||||
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||||
const issue = makeIssue(String(index + 1), index < 3);
|
const issue = makeIssue(String(index + 1), index < 3);
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques
|
|||||||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
export type InboxTab = "recent" | "unread" | "all";
|
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 {
|
export interface InboxBadgeData {
|
||||||
inbox: number;
|
inbox: number;
|
||||||
@@ -104,6 +116,85 @@ export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
|
|||||||
return issues.filter((issue) => issue.isUnreadForMe);
|
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({
|
export function computeInboxBadgeData({
|
||||||
approvals,
|
approvals,
|
||||||
joinRequests,
|
joinRequests,
|
||||||
|
|||||||
80
ui/src/lib/onboarding-route.test.ts
Normal file
80
ui/src/lib/onboarding-route.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
ui/src/lib/onboarding-route.ts
Normal file
51
ui/src/lib/onboarding-route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -701,8 +701,8 @@ export function AgentDetail() {
|
|||||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||||
} else if (activeView === "configuration") {
|
} else if (activeView === "configuration") {
|
||||||
crumbs.push({ label: "Configuration" });
|
crumbs.push({ label: "Configuration" });
|
||||||
} else if (activeView === "skills") {
|
// } else if (activeView === "skills") { // TODO: bring back later
|
||||||
crumbs.push({ label: "Skills" });
|
// crumbs.push({ label: "Skills" });
|
||||||
} else if (activeView === "runs") {
|
} else if (activeView === "runs") {
|
||||||
crumbs.push({ label: "Runs" });
|
crumbs.push({ label: "Runs" });
|
||||||
} else if (activeView === "budget") {
|
} else if (activeView === "budget") {
|
||||||
@@ -862,7 +862,7 @@ export function AgentDetail() {
|
|||||||
items={[
|
items={[
|
||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "skills", label: "Skills" },
|
// { value: "skills", label: "Skills" }, // TODO: bring back later
|
||||||
{ value: "runs", label: "Runs" },
|
{ value: "runs", label: "Runs" },
|
||||||
{ value: "budget", label: "Budget" },
|
{ value: "budget", label: "Budget" },
|
||||||
]}
|
]}
|
||||||
@@ -955,11 +955,11 @@ export function AgentDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeView === "skills" && (
|
{/* {activeView === "skills" && (
|
||||||
<SkillsTab
|
<SkillsTab
|
||||||
agent={agent}
|
agent={agent}
|
||||||
/>
|
/>
|
||||||
)}
|
)} */}{/* TODO: bring back later */}
|
||||||
|
|
||||||
{activeView === "runs" && (
|
{activeView === "runs" && (
|
||||||
<RunsTab
|
<RunsTab
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { ApprovalCard } from "../components/ApprovalCard";
|
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -40,13 +40,17 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
|
getApprovalsForTab,
|
||||||
|
getInboxWorkItems,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
type InboxTab,
|
InboxApprovalFilter,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
|
shouldShowInboxSection,
|
||||||
|
type InboxTab,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
@@ -57,11 +61,9 @@ type InboxCategoryFilter =
|
|||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
|
||||||
type SectionKey =
|
type SectionKey =
|
||||||
| "issues_i_touched"
|
| "work_items"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
|
|
||||||
@@ -82,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string {
|
|||||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
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 {
|
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
const context = run.contextSnapshot;
|
const context = run.contextSnapshot;
|
||||||
if (!context) return null;
|
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() {
|
export function Inbox() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
@@ -334,6 +429,10 @@ export function Inbox() {
|
|||||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||||
[touchedIssues],
|
[touchedIssues],
|
||||||
);
|
);
|
||||||
|
const issuesToRender = useMemo(
|
||||||
|
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
|
||||||
|
[tab, touchedIssues, unreadTouchedIssues],
|
||||||
|
);
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -361,28 +460,28 @@ export function Inbox() {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [heartbeatRuns]);
|
}, [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(
|
getInboxWorkItems({
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||||
),
|
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||||
[approvals],
|
}),
|
||||||
|
[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) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return agentById.get(id) ?? null;
|
return agentById.get(id) ?? null;
|
||||||
@@ -505,39 +604,29 @@ export function Inbox() {
|
|||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const showWorkItemsSection = workItemsToRender.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 showJoinRequestsSection =
|
const showJoinRequestsSection =
|
||||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||||
const showApprovalsSection = tab === "all"
|
const showFailedRunsSection = shouldShowInboxSection({
|
||||||
? showApprovalsCategory && filteredAllApprovals.length > 0
|
tab,
|
||||||
: actionableApprovals.length > 0;
|
hasItems: hasRunFailures,
|
||||||
const showFailedRunsSection =
|
showOnRecent: hasRunFailures,
|
||||||
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
|
showOnUnread: hasRunFailures,
|
||||||
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
|
showOnAll: showFailedRunsCategory && hasRunFailures,
|
||||||
|
});
|
||||||
|
const showAlertsSection = shouldShowInboxSection({
|
||||||
|
tab,
|
||||||
|
hasItems: hasAlerts,
|
||||||
|
showOnRecent: hasAlerts,
|
||||||
|
showOnUnread: hasAlerts,
|
||||||
|
showOnAll: showAlertsCategory && hasAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showApprovalsSection ? "approvals" : null,
|
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
showTouchedSection ? "issues_i_touched" : null,
|
showWorkItemsSection ? "work_items" : null,
|
||||||
].filter((key): key is SectionKey => key !== null);
|
].filter((key): key is SectionKey => key !== null);
|
||||||
|
|
||||||
const allLoaded =
|
const allLoaded =
|
||||||
@@ -643,29 +732,72 @@ export function Inbox() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showApprovalsSection && (
|
{showWorkItemsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("approvals") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
|
{workItemsToRender.map((item) => {
|
||||||
</h3>
|
if (item.kind === "approval") {
|
||||||
<div className="grid gap-3">
|
return (
|
||||||
{approvalsToRender.map((approval) => (
|
<ApprovalInboxRow
|
||||||
<ApprovalCard
|
key={`approval:${item.approval.id}`}
|
||||||
key={approval.id}
|
approval={item.approval}
|
||||||
approval={approval}
|
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||||
requesterAgent={
|
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||||
approval.requestedByAgentId
|
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||||
? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
: null
|
/>
|
||||||
}
|
);
|
||||||
onApprove={() => approveMutation.mutate(approval.id)}
|
}
|
||||||
onReject={() => rejectMutation.mutate(approval.id)}
|
|
||||||
detailLink={`/approvals/${approval.id}`}
|
const issue = item.issue;
|
||||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user