mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
commit
9e19f1d005
49 changed files with 3997 additions and 2501 deletions
|
|
@ -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";
|
||||
|
|
@ -43,6 +42,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 (
|
||||
|
|
@ -181,24 +181,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
|
||||
|
|
@ -233,19 +222,22 @@ function OnboardingRoutePage() {
|
|||
|
||||
function CompanyRootRedirect() {
|
||||
const { companies, selectedCompany, loading } = useCompany();
|
||||
const { onboardingOpen } = useDialog();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
// Keep the first-run onboarding mounted until it completes.
|
||||
if (onboardingOpen) {
|
||||
return <NoCompaniesStartPage autoOpen={false} />;
|
||||
}
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +254,14 @@ function UnprefixedBoardRedirect() {
|
|||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
|
|
@ -273,16 +273,8 @@ function UnprefixedBoardRedirect() {
|
|||
);
|
||||
}
|
||||
|
||||
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
|
||||
function NoCompaniesStartPage() {
|
||||
const { openOnboarding } = useDialog();
|
||||
const opened = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
if (opened.current) return;
|
||||
opened.current = true;
|
||||
openOnboarding();
|
||||
}, [autoOpen, openOnboarding]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -420,12 +415,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
heartbeat: mergedHeartbeat,
|
||||
};
|
||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||
const sessionCompaction = useMemo(
|
||||
() => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig),
|
||||
[adapterType, effectiveRuntimeConfig],
|
||||
);
|
||||
const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
|
|
@ -735,6 +724,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
|
||||
<>
|
||||
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"bootstrapPromptTemplate",
|
||||
String(config.bootstrapPromptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) =>
|
||||
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||
}
|
||||
placeholder="Optional initial setup prompt for the first run"
|
||||
contentClassName="min-h-[44px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
|
@ -831,12 +846,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
numberHint={help.intervalSec}
|
||||
showNumber={val!.heartbeatEnabled}
|
||||
/>
|
||||
{showSessionCompactionCard && (
|
||||
<SessionCompactionPolicyCard
|
||||
adapterType={adapterType}
|
||||
resolution={sessionCompaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : !isCreate ? (
|
||||
|
|
@ -859,12 +868,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
/>
|
||||
{showSessionCompactionCard && (
|
||||
<SessionCompactionPolicyCard
|
||||
adapterType={adapterType}
|
||||
resolution={sessionCompaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
title="Advanced Run Policy"
|
||||
|
|
@ -952,69 +955,6 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
);
|
||||
}
|
||||
|
||||
function formatSessionThreshold(value: number, suffix: string) {
|
||||
if (value <= 0) return "Off";
|
||||
return `${value.toLocaleString("en-US")} ${suffix}`;
|
||||
}
|
||||
|
||||
function SessionCompactionPolicyCard({
|
||||
adapterType,
|
||||
resolution,
|
||||
}: {
|
||||
adapterType: string;
|
||||
resolution: ResolvedSessionCompactionPolicy;
|
||||
}) {
|
||||
const { adapterSessionManagement, policy, source } = resolution;
|
||||
if (!adapterSessionManagement) return null;
|
||||
|
||||
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
|
||||
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
|
||||
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
|
||||
const nativeSummary =
|
||||
adapterSessionManagement.nativeContextManagement === "confirmed"
|
||||
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
|
||||
: adapterSessionManagement.nativeContextManagement === "likely"
|
||||
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
|
||||
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium text-sky-50">Session compaction</div>
|
||||
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sky-100/90">
|
||||
{nativeSummary}
|
||||
</p>
|
||||
<p className="text-xs text-sky-100/80">
|
||||
{rotationDisabled
|
||||
? "No Paperclip-managed fresh-session thresholds are active for this adapter."
|
||||
: "Paperclip will start a fresh session when one of these thresholds is reached."}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
|
||||
<div>
|
||||
<div className="text-sky-100/60">Runs</div>
|
||||
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sky-100/60">Raw input</div>
|
||||
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sky-100/60">Age</div>
|
||||
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-sky-100/75">
|
||||
A large cumulative raw token total does not mean the full session is resent on every heartbeat.
|
||||
{source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
|
|
@ -298,7 +299,12 @@ export function Layout() {
|
|||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
|
|
@ -351,7 +357,12 @@ export function Layout() {
|
|||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { HintIcon } from "./agent-config-primitives";
|
||||
|
|
@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an
|
|||
|
||||
export function OnboardingWizard() {
|
||||
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
||||
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
|
||||
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||
|
||||
const initialStep = onboardingOptions.initialStep ?? 1;
|
||||
const existingCompanyId = onboardingOptions.companyId;
|
||||
const routeOnboardingOptions =
|
||||
companyPrefix && companiesLoading
|
||||
? null
|
||||
: resolveRouteOnboardingOptions({
|
||||
pathname: location.pathname,
|
||||
companyPrefix,
|
||||
companies,
|
||||
});
|
||||
const effectiveOnboardingOpen =
|
||||
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
|
||||
const effectiveOnboardingOptions = onboardingOpen
|
||||
? onboardingOptions
|
||||
: routeOnboardingOptions ?? {};
|
||||
|
||||
const initialStep = effectiveOnboardingOptions.initialStep ?? 1;
|
||||
const existingCompanyId = effectiveOnboardingOptions.companyId;
|
||||
|
||||
const [step, setStep] = useState<Step>(initialStep);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -134,27 +152,31 @@ export function OnboardingWizard() {
|
|||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRouteDismissed(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Sync step and company when onboarding opens with options.
|
||||
// Keep this independent from company-list refreshes so Step 1 completion
|
||||
// doesn't get reset after creating a company.
|
||||
useEffect(() => {
|
||||
if (!onboardingOpen) return;
|
||||
const cId = onboardingOptions.companyId ?? null;
|
||||
setStep(onboardingOptions.initialStep ?? 1);
|
||||
if (!effectiveOnboardingOpen) return;
|
||||
const cId = effectiveOnboardingOptions.companyId ?? null;
|
||||
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||
setCreatedCompanyId(cId);
|
||||
setCreatedCompanyPrefix(null);
|
||||
}, [
|
||||
onboardingOpen,
|
||||
onboardingOptions.companyId,
|
||||
onboardingOptions.initialStep
|
||||
effectiveOnboardingOpen,
|
||||
effectiveOnboardingOptions.companyId,
|
||||
effectiveOnboardingOptions.initialStep
|
||||
]);
|
||||
|
||||
// Backfill issue prefix for an existing company once companies are loaded.
|
||||
useEffect(() => {
|
||||
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
||||
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
||||
const company = companies.find((c) => c.id === createdCompanyId);
|
||||
if (company) setCreatedCompanyPrefix(company.issuePrefix);
|
||||
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
||||
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
||||
|
||||
// Resize textarea when step 3 is shown or description changes
|
||||
useEffect(() => {
|
||||
|
|
@ -171,7 +193,7 @@ export function OnboardingWizard() {
|
|||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||
});
|
||||
const isLocalAdapter =
|
||||
adapterType === "claude_local" ||
|
||||
|
|
@ -546,13 +568,16 @@ export function OnboardingWizard() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!onboardingOpen) return null;
|
||||
if (!effectiveOnboardingOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={onboardingOpen}
|
||||
open={effectiveOnboardingOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
if (!open) {
|
||||
setRouteDismissed(true);
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPortal>
|
||||
|
|
@ -762,6 +787,12 @@ export function OnboardingWizard() {
|
|||
icon: Gem,
|
||||
desc: "Local Gemini agent"
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Process",
|
||||
icon: Terminal,
|
||||
desc: "Run a local command"
|
||||
},
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
computeInboxBadgeData,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
loadLastInboxTab,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
} from "./inbox";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
|
@ -46,6 +49,19 @@ function makeApproval(status: Approval["status"]): Approval {
|
|||
};
|
||||
}
|
||||
|
||||
function makeApprovalWithTimestamps(
|
||||
id: string,
|
||||
status: Approval["status"],
|
||||
updatedAt: string,
|
||||
): Approval {
|
||||
return {
|
||||
...makeApproval(status),
|
||||
id,
|
||||
createdAt: new Date(updatedAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function makeJoinRequest(id: string): JoinRequest {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -231,6 +247,77 @@ describe("inbox helpers", () => {
|
|||
expect(issues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows recent approvals in updated order and unread approvals as actionable only", () => {
|
||||
const approvals = [
|
||||
makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
|
||||
makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
|
||||
makeApprovalWithTimestamps(
|
||||
"approval-revision",
|
||||
"revision_requested",
|
||||
"2026-03-11T03:00:00.000Z",
|
||||
),
|
||||
];
|
||||
|
||||
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
"approval-pending",
|
||||
]);
|
||||
expect(getApprovalsForTab(approvals, "unread", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-pending",
|
||||
]);
|
||||
expect(getApprovalsForTab(approvals, "all", "resolved").map((approval) => approval.id)).toEqual([
|
||||
"approval-approved",
|
||||
]);
|
||||
});
|
||||
|
||||
it("mixes approvals into the inbox feed by most recent activity", () => {
|
||||
const newerIssue = makeIssue("1", true);
|
||||
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
const olderIssue = makeIssue("2", false);
|
||||
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
const approval = makeApprovalWithTimestamps(
|
||||
"approval-between",
|
||||
"pending",
|
||||
"2026-03-11T03:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(
|
||||
getInboxWorkItems({
|
||||
issues: [olderIssue, newerIssue],
|
||||
approvals: [approval],
|
||||
}).map((item) => item.kind === "issue" ? `issue:${item.issue.id}` : `approval:${item.approval.id}`),
|
||||
).toEqual([
|
||||
"issue:1",
|
||||
"approval:approval-between",
|
||||
"issue:2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can include sections on recent without forcing them to be unread", () => {
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "recent",
|
||||
hasItems: true,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "unread",
|
||||
hasItems: true,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("limits recent touched issues before unread badge counting", () => {
|
||||
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||
const issue = makeIssue(String(index + 1), index < 3);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -731,8 +731,8 @@ export function AgentDetail() {
|
|||
crumbs.push({ label: "Instructions" });
|
||||
} 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") {
|
||||
|
|
@ -892,8 +892,9 @@ export function AgentDetail() {
|
|||
items={[
|
||||
{ value: "dashboard", label: "Dashboard" },
|
||||
{ value: "instructions", label: "Instructions" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
value={activeView}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -40,13 +40,17 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
type InboxTab,
|
||||
InboxApprovalFilter,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
|
|
@ -57,11 +61,9 @@ type InboxCategoryFilter =
|
|||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
type SectionKey =
|
||||
| "issues_i_touched"
|
||||
| "work_items"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
|
||||
|
|
@ -82,6 +84,10 @@ function runFailureMessage(run: HeartbeatRun): string {
|
|||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||
}
|
||||
|
||||
function approvalStatusLabel(status: Approval["status"]): string {
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
const context = run.contextSnapshot;
|
||||
if (!context) return null;
|
||||
|
|
@ -233,6 +239,95 @@ function FailedRunCard({
|
|||
);
|
||||
}
|
||||
|
||||
function ApprovalInboxRow({
|
||||
approval,
|
||||
requesterName,
|
||||
onApprove,
|
||||
onReject,
|
||||
isPending,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterName: string | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
const showResolutionButtons =
|
||||
approval.type !== "budget_override_required" &&
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
|
||||
<div className="flex items-start gap-2 sm:items-center">
|
||||
<Link
|
||||
to={`/approvals/${approval.id}`}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="capitalize">{approvalStatusLabel(approval.status)}</span>
|
||||
{requesterName ? <span>requested by {requesterName}</span> : null}
|
||||
<span>updated {timeAgo(approval.updatedAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
{showResolutionButtons ? (
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showResolutionButtons ? (
|
||||
<div className="mt-3 flex gap-2 sm:hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Inbox() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -334,6 +429,10 @@ export function Inbox() {
|
|||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
);
|
||||
const issuesToRender = useMemo(
|
||||
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
|
||||
[tab, touchedIssues, unreadTouchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
|
|
@ -361,28 +460,28 @@ export function Inbox() {
|
|||
return ids;
|
||||
}, [heartbeatRuns]);
|
||||
|
||||
const allApprovals = useMemo(
|
||||
const approvalsToRender = useMemo(
|
||||
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
|
||||
[approvals, tab, allApprovalFilter],
|
||||
);
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showTouchedCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched";
|
||||
const showApprovalsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
const workItemsToRender = useMemo(
|
||||
() =>
|
||||
[...(approvals ?? [])].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[approvals],
|
||||
getInboxWorkItems({
|
||||
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||
}),
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
|
||||
);
|
||||
|
||||
const actionableApprovals = useMemo(
|
||||
() => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)),
|
||||
[allApprovals],
|
||||
);
|
||||
|
||||
const filteredAllApprovals = useMemo(() => {
|
||||
if (allApprovalFilter === "all") return allApprovals;
|
||||
|
||||
return allApprovals.filter((approval) => {
|
||||
const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||
return allApprovalFilter === "actionable" ? isActionable : !isActionable;
|
||||
});
|
||||
}, [allApprovals, allApprovalFilter]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agentById.get(id) ?? null;
|
||||
|
|
@ -505,39 +604,29 @@ export function Inbox() {
|
|||
!dismissed.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const hasJoinRequests = joinRequests.length > 0;
|
||||
const hasTouchedIssues = touchedIssues.length > 0;
|
||||
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showTouchedCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched";
|
||||
const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals";
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
|
||||
const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals;
|
||||
const showTouchedSection =
|
||||
tab === "all"
|
||||
? showTouchedCategory && hasTouchedIssues
|
||||
: tab === "unread"
|
||||
? unreadTouchedIssues.length > 0
|
||||
: hasTouchedIssues;
|
||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||
const showJoinRequestsSection =
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||
const showApprovalsSection = tab === "all"
|
||||
? showApprovalsCategory && filteredAllApprovals.length > 0
|
||||
: actionableApprovals.length > 0;
|
||||
const showFailedRunsSection =
|
||||
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
|
||||
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
|
||||
const showFailedRunsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasRunFailures,
|
||||
showOnRecent: hasRunFailures,
|
||||
showOnUnread: hasRunFailures,
|
||||
showOnAll: showFailedRunsCategory && hasRunFailures,
|
||||
});
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
showOnRecent: hasAlerts,
|
||||
showOnUnread: hasAlerts,
|
||||
showOnAll: showAlertsCategory && hasAlerts,
|
||||
});
|
||||
|
||||
const visibleSections = [
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showApprovalsSection ? "approvals" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showTouchedSection ? "issues_i_touched" : null,
|
||||
showWorkItemsSection ? "work_items" : null,
|
||||
].filter((key): key is SectionKey => key !== null);
|
||||
|
||||
const allLoaded =
|
||||
|
|
@ -643,29 +732,72 @@ export function Inbox() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{showApprovalsSection && (
|
||||
{showWorkItemsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("approvals") && <Separator />}
|
||||
{showSeparatorBefore("work_items") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{approvalsToRender.map((approval) => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
requesterAgent={
|
||||
approval.requestedByAgentId
|
||||
? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null
|
||||
: null
|
||||
}
|
||||
onApprove={() => approveMutation.mutate(approval.id)}
|
||||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.map((item) => {
|
||||
if (item.kind === "approval") {
|
||||
return (
|
||||
<ApprovalInboxRow
|
||||
key={`approval:${item.approval.id}`}
|
||||
approval={item.approval}
|
||||
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
return (
|
||||
<IssueRow
|
||||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
</span>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
mobileMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
trailingMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -806,62 +938,6 @@ export function Inbox() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{showTouchedSection && (
|
||||
<>
|
||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||
<div>
|
||||
<div>
|
||||
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
return (
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
</span>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
mobileMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
trailingMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { authApi } from "../api/auth";
|
|||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
|
|
@ -36,8 +37,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
EyeOff,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
|
|
@ -196,7 +199,9 @@ export function IssueDetail() {
|
|||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { pushToast } = useToast();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
|
|
@ -585,6 +590,22 @@ export function IssueDetail() {
|
|||
return () => closePanel();
|
||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const copyIssueToClipboard = async () => {
|
||||
if (!issue) return;
|
||||
const decodeEntities = (text: string) => {
|
||||
const el = document.createElement("textarea");
|
||||
el.innerHTML = text;
|
||||
return el.value;
|
||||
};
|
||||
const title = decodeEntities(issue.title);
|
||||
const body = decodeEntities(issue.description ?? "");
|
||||
const md = `# ${issue.identifier}: ${title}\n\n${body}`.trimEnd();
|
||||
await navigator.clipboard.writeText(md);
|
||||
setCopied(true);
|
||||
pushToast({ title: "Copied to clipboard", tone: "success" });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!issue) return null;
|
||||
|
|
@ -737,17 +758,34 @@ export function IssueDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto md:hidden shrink-0"
|
||||
onClick={() => setMobilePropsOpen(true)}
|
||||
title="Properties"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-0.5 md:hidden shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setMobilePropsOpen(true)}
|
||||
title="Properties"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center md:ml-auto shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue